diff --git a/.idea/misc.xml b/.idea/misc.xml index b9eee8c01..86adf2483 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -30,7 +30,7 @@ - + diff --git a/agent/build.gradle.kts b/agent/build.gradle.kts index 300483c52..5ac117c7f 100644 --- a/agent/build.gradle.kts +++ b/agent/build.gradle.kts @@ -1,4 +1,5 @@ plugins { + com.teamscale.`kotlin-convention` com.teamscale.`java-convention` application diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/Agent.java b/agent/src/main/java/com/teamscale/jacoco/agent/Agent.java deleted file mode 100644 index 9f82fe9e1..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/Agent.java +++ /dev/null @@ -1,201 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2018 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent; - -import com.teamscale.client.FileSystemUtils; -import com.teamscale.client.StringUtils; -import com.teamscale.jacoco.agent.options.AgentOptions; -import com.teamscale.jacoco.agent.upload.IUploadRetry; -import com.teamscale.jacoco.agent.upload.IUploader; -import com.teamscale.jacoco.agent.upload.UploaderException; -import com.teamscale.jacoco.agent.util.AgentUtils; -import com.teamscale.jacoco.agent.util.Benchmark; -import com.teamscale.jacoco.agent.util.Timer; -import com.teamscale.report.jacoco.CoverageFile; -import com.teamscale.report.jacoco.EmptyReportException; -import com.teamscale.report.jacoco.JaCoCoXmlReportGenerator; -import com.teamscale.report.jacoco.dump.Dump; -import org.glassfish.jersey.server.ResourceConfig; -import org.glassfish.jersey.server.ServerProperties; - -import java.io.File; -import java.io.IOException; -import java.lang.instrument.Instrumentation; -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Duration; -import java.util.List; -import java.util.Properties; -import java.util.stream.Stream; - -import static com.teamscale.jacoco.agent.logging.LoggingUtils.wrap; -import static com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader.RETRY_UPLOAD_FILE_SUFFIX; - -/** - * A wrapper around the JaCoCo Java agent that automatically triggers a dump and XML conversion based on a time - * interval. - */ -public class Agent extends AgentBase { - - /** Converts binary data to XML. */ - private final JaCoCoXmlReportGenerator generator; - - /** Regular dump task. */ - private Timer timer; - - /** Stores the XML files. */ - protected final IUploader uploader; - - /** Constructor. */ - public Agent(AgentOptions options, Instrumentation instrumentation) - throws IllegalStateException, UploaderException { - super(options); - - uploader = options.createUploader(instrumentation); - logger.info("Upload method: {}", uploader.describe()); - retryUnsuccessfulUploads(options, uploader); - generator = new JaCoCoXmlReportGenerator(options.getClassDirectoriesOrZips(), - options.getLocationIncludeFilter(), options.getDuplicateClassFileBehavior(), - options.shouldIgnoreUncoveredClasses(), wrap(logger)); - - if (options.shouldDumpInIntervals()) { - timer = new Timer(this::dumpReport, Duration.ofMinutes(options.getDumpIntervalInMinutes())); - timer.start(); - logger.info("Dumping every {} minutes.", options.getDumpIntervalInMinutes()); - } - if (options.getTeamscaleServerOptions().partition != null) { - controller.setSessionId(options.getTeamscaleServerOptions().partition); - } - } - - /** - * If we have coverage that was leftover because of previously unsuccessful coverage uploads, we retry to upload - * them again with the same configuration as in the previous try. - */ - private void retryUnsuccessfulUploads(AgentOptions options, IUploader uploader) { - Path outputPath = options.getOutputDirectory(); - if (outputPath == null) { - // Default fallback - outputPath = AgentUtils.getAgentDirectory().resolve("coverage"); - } - - Path parentPath = outputPath.getParent(); - if (parentPath == null) { - logger.error("The output path '{}' does not have a parent path. Canceling upload retry.", - outputPath.toAbsolutePath()); - return; - } - - List reuploadCandidates = FileSystemUtils.listFilesRecursively(parentPath.toFile(), - filepath -> filepath.getName().endsWith(RETRY_UPLOAD_FILE_SUFFIX)); - for (File file : reuploadCandidates) { - reuploadCoverageFromPropertiesFile(file, uploader); - } - } - - private void reuploadCoverageFromPropertiesFile(File file, IUploader uploader) { - logger.info("Retrying previously unsuccessful coverage upload for file {}.", file); - try { - Properties properties = FileSystemUtils.readProperties(file); - CoverageFile coverageFile = new CoverageFile( - new File(StringUtils.stripSuffix(file.getAbsolutePath(), RETRY_UPLOAD_FILE_SUFFIX))); - - if (uploader instanceof IUploadRetry) { - ((IUploadRetry) uploader).reupload(coverageFile, properties); - } else { - logger.info("Reupload not implemented for uploader {}", uploader.describe()); - } - Files.deleteIfExists(file.toPath()); - } catch (IOException e) { - logger.error("Reuploading coverage failed. " + e); - } - } - - @Override - protected ResourceConfig initResourceConfig() { - ResourceConfig resourceConfig = new ResourceConfig(); - resourceConfig.property(ServerProperties.WADL_FEATURE_DISABLE, Boolean.TRUE.toString()); - AgentResource.setAgent(this); - return resourceConfig.register(AgentResource.class).register(GenericExceptionMapper.class); - } - - @Override - protected void prepareShutdown() { - if (timer != null) { - timer.stop(); - } - if (options.shouldDumpOnExit()) { - dumpReport(); - } - - try { - deleteDirectoryIfEmpty(options.getOutputDirectory()); - } catch (IOException e) { - logger.info( - "Could not delete empty output directory {}. " - + "This directory was created inside the configured output directory to be able to " - + "distinguish between different runs of the profiled JVM. You may delete it manually.", - options.getOutputDirectory(), e); - } - } - - /** - * Delete a directory from disk if it is empty. This method does nothing if the path provided does not exist or - * point to a file. - * - * @throws IOException if the deletion of the directory fails - */ - private static void deleteDirectoryIfEmpty(Path directory) throws IOException { - if (!Files.isDirectory(directory)) { - return; - } - - try (Stream stream = Files.list(directory)) { - if (stream.findFirst().isPresent()) { - return; - } - } - - Files.delete(directory); - } - - /** - * Dumps the current execution data, converts it, writes it to the output directory defined in {@link #options} and - * uploads it if an uploader is configured. Logs any errors, never throws an exception. - */ - @Override - public void dumpReport() { - logger.debug("Starting dump"); - - try { - dumpReportUnsafe(); - } catch (Throwable t) { - // we want to catch anything in order to avoid crashing the whole system under - // test - logger.error("Dump job failed with an exception", t); - } - } - - private void dumpReportUnsafe() { - Dump dump; - try { - dump = controller.dumpAndReset(); - } catch (JacocoRuntimeController.DumpException e) { - logger.error("Dumping failed, retrying later", e); - return; - } - - try (Benchmark ignored = new Benchmark("Generating the XML report")) { - File outputFile = options.createNewFileInOutputDirectory("jacoco", "xml"); - CoverageFile coverageFile = generator.convertSingleDumpToReport(dump, outputFile); - uploader.upload(coverageFile); - } catch (IOException e) { - logger.error("Converting binary dump to XML failed", e); - } catch (EmptyReportException e) { - logger.error("No coverage was collected. " + e.getMessage(), e); - } - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/AgentBase.java b/agent/src/main/java/com/teamscale/jacoco/agent/AgentBase.java deleted file mode 100644 index 13b98f37e..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/AgentBase.java +++ /dev/null @@ -1,157 +0,0 @@ -package com.teamscale.jacoco.agent; - -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.options.AgentOptions; -import org.eclipse.jetty.server.Server; -import org.eclipse.jetty.server.ServerConnector; -import org.eclipse.jetty.servlet.ServletContextHandler; -import org.eclipse.jetty.servlet.ServletHolder; -import org.eclipse.jetty.util.thread.QueuedThreadPool; -import org.glassfish.jersey.server.ResourceConfig; -import org.glassfish.jersey.servlet.ServletContainer; -import org.jacoco.agent.rt.RT; -import org.slf4j.Logger; - -import java.lang.management.ManagementFactory; - -/** - * Base class for agent implementations. Handles logger shutdown, store creation and instantiation of the - * {@link JacocoRuntimeController}. - *

- * Subclasses must handle dumping onto disk and uploading via the configured uploader. - */ -public abstract class AgentBase { - - /** The logger. */ - protected final Logger logger = LoggingUtils.getLogger(this); - - /** Controls the JaCoCo runtime. */ - public final JacocoRuntimeController controller; - - /** The agent options. */ - protected AgentOptions options; - - private Server server; - - /** Constructor. */ - public AgentBase(AgentOptions options) throws IllegalStateException { - this.options = options; - - try { - controller = new JacocoRuntimeController(RT.getAgent()); - } catch (IllegalStateException e) { - throw new IllegalStateException( - "Teamscale Java Profiler not started or there is a conflict with another agent on the classpath.", - e); - } - logger.info("Starting Teamscale Java Profiler for process {} with options: {}", - ManagementFactory.getRuntimeMXBean().getName(), getOptionsObjectToLog()); - if (options.getHttpServerPort() != null) { - try { - initServer(); - } catch (Exception e) { - logger.error("Could not start http server on port " + options.getHttpServerPort() - + ". Please check if the port is blocked."); - throw new IllegalStateException("Control server not started.", e); - } - } - } - - - - /** - * Lazily generated string representation of the command line arguments to print to the log. - */ - private Object getOptionsObjectToLog() { - return new Object() { - @Override - public String toString() { - if (options.shouldObfuscateSecurityRelatedOutputs()) { - return options.getObfuscatedOptionsString(); - } - return options.getOriginalOptionsString(); - } - }; - } - - /** - * Starts the http server, which waits for information about started and finished tests. - */ - private void initServer() throws Exception { - logger.info("Listening for test events on port {}.", options.getHttpServerPort()); - - // Jersey Implementation - ServletContextHandler handler = buildUsingResourceConfig(); - QueuedThreadPool threadPool = new QueuedThreadPool(); - threadPool.setMaxThreads(10); - threadPool.setDaemon(true); - - // Create a server instance and set the thread pool - server = new Server(threadPool); - // Create a server connector, set the port and add it to the server - ServerConnector connector = new ServerConnector(server); - connector.setPort(options.getHttpServerPort()); - server.addConnector(connector); - server.setHandler(handler); - server.start(); - } - - private ServletContextHandler buildUsingResourceConfig() { - ServletContextHandler handler = new ServletContextHandler(ServletContextHandler.NO_SESSIONS); - handler.setContextPath("/"); - - ResourceConfig resourceConfig = initResourceConfig(); - handler.addServlet(new ServletHolder(new ServletContainer(resourceConfig)), "/*"); - return handler; - } - - /** - * Initializes the {@link ResourceConfig} needed for the Jetty + Jersey Server - */ - protected abstract ResourceConfig initResourceConfig(); - - /** - * Registers a shutdown hook that stops the timer and dumps coverage a final time. - */ - void registerShutdownHook() { - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - try { - logger.info("Teamscale Java Profiler is shutting down..."); - stopServer(); - prepareShutdown(); - logger.info("Teamscale Java Profiler successfully shut down."); - } catch (Exception e) { - logger.error("Exception during profiler shutdown.", e); - } finally { - // Try to flush logging resources also in case of an exception during shutdown - PreMain.closeLoggingResources(); - } - })); - } - - /** Stop the http server if it's running */ - void stopServer() { - if (options.getHttpServerPort() != null) { - try { - server.stop(); - } catch (Exception e) { - logger.error("Could not stop server so it is killed now.", e); - } finally { - server.destroy(); - } - } - } - - /** Called when the shutdown hook is triggered. */ - protected void prepareShutdown() { - // Template method to be overridden by subclasses. - } - - /** - * Dumps the current execution data, converts it, writes it to the output - * directory defined in {@link #options} and uploads it if an uploader is - * configured. Logs any errors, never throws an exception. - */ - public abstract void dumpReport(); - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/AgentResource.java b/agent/src/main/java/com/teamscale/jacoco/agent/AgentResource.java deleted file mode 100644 index 51684e739..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/AgentResource.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.teamscale.jacoco.agent; - -import javax.ws.rs.POST; -import javax.ws.rs.Path; -import javax.ws.rs.core.Response; - -/** - * The resource of the Jersey + Jetty http server holding all the endpoints specific for the {@link Agent}. - */ -@Path("/") -public class AgentResource extends ResourceBase { - - private static Agent agent; - - /** - * Static setter to inject the {@link Agent} to the resource. - */ - public static void setAgent(Agent agent) { - AgentResource.agent = agent; - ResourceBase.agentBase = agent; - } - - /** Handles dumping a XML coverage report for coverage collected until now. */ - @POST - @Path("/dump") - public Response handleDump() { - logger.debug("Dumping report triggered via HTTP request"); - agent.dumpReport(); - return Response.noContent().build(); - } - - /** Handles resetting of coverage. */ - @POST - @Path("/reset") - public Response handleReset() { - logger.debug("Resetting coverage triggered via HTTP request"); - agent.controller.reset(); - return Response.noContent().build(); - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/DelayedLogger.java b/agent/src/main/java/com/teamscale/jacoco/agent/DelayedLogger.java deleted file mode 100644 index 574389c58..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/DelayedLogger.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.teamscale.jacoco.agent; - -import com.teamscale.report.util.ILogger; -import org.slf4j.Logger; - -import java.util.ArrayList; -import java.util.List; - -/** - * A logger that buffers logs in memory and writes them to the actual logger at a later point. This is needed when stuff - * needs to be logged before the actual logging framework is initialized. - */ -public class DelayedLogger implements ILogger { - - /** List of log actions that will be executed once the logger is initialized. */ - private final List logActions = new ArrayList<>(); - - @Override - public void debug(String message) { - logActions.add(logger -> logger.debug(message)); - } - - @Override - public void info(String message) { - logActions.add(logger -> logger.info(message)); - } - - @Override - public void warn(String message) { - logActions.add(logger -> logger.warn(message)); - } - - @Override - public void warn(String message, Throwable throwable) { - logActions.add(logger -> logger.warn(message, throwable)); - } - - @Override - public void error(Throwable throwable) { - logActions.add(logger -> logger.error(throwable.getMessage(), throwable)); - } - - @Override - public void error(String message, Throwable throwable) { - logActions.add(logger -> logger.error(message, throwable)); - } - - /** - * Logs an error and also writes the message to {@link System#err} to ensure the message is even logged in case - * setting up the logger itself fails for some reason (see TS-23151). - */ - public void errorAndStdErr(String message, Throwable throwable) { - System.err.println(message); - logActions.add(logger -> logger.error(message, throwable)); - } - - /** Writes the logs to the given slf4j logger. */ - public void logTo(Logger logger) { - logActions.forEach(action -> action.log(logger)); - } - - /** An action to be executed on a logger. */ - private interface ILoggerAction { - - /** Executes the action on the given logger. */ - void log(Logger logger); - - } -} - diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/GenericExceptionMapper.java b/agent/src/main/java/com/teamscale/jacoco/agent/GenericExceptionMapper.java deleted file mode 100644 index 76c1d043f..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/GenericExceptionMapper.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.teamscale.jacoco.agent; - -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import javax.ws.rs.ext.ExceptionMapper; - -/** - * Generates a {@link Response} for an exception. - */ -@javax.ws.rs.ext.Provider -public class GenericExceptionMapper implements ExceptionMapper { - - @Override - public Response toResponse(Throwable e) { - Response.ResponseBuilder errorResponse = Response.status(Response.Status.INTERNAL_SERVER_ERROR); - errorResponse.type(MediaType.TEXT_PLAIN_TYPE); - errorResponse.entity("Message: " + e.getMessage()); - return errorResponse.build(); - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/JacocoRuntimeController.java b/agent/src/main/java/com/teamscale/jacoco/agent/JacocoRuntimeController.java deleted file mode 100644 index 1ad9b2146..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/JacocoRuntimeController.java +++ /dev/null @@ -1,139 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2018 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent; - -import com.teamscale.report.jacoco.dump.Dump; -import org.jacoco.agent.rt.IAgent; -import org.jacoco.agent.rt.RT; -import org.jacoco.core.data.ExecutionDataReader; -import org.jacoco.core.data.ExecutionDataStore; -import org.jacoco.core.data.ISessionInfoVisitor; -import org.jacoco.core.data.SessionInfo; - -import java.io.ByteArrayInputStream; -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; - -/** - * Wrapper around JaCoCo's {@link RT} runtime interface. - *

- * Can be used if the calling code is run in the same JVM as the agent is attached to. - */ -public class JacocoRuntimeController { - - /** Indicates a failed dump. */ - public static class DumpException extends Exception { - - /** Serialization ID. */ - private static final long serialVersionUID = 1L; - - /** Constructor. */ - public DumpException(String message, Throwable cause) { - super(message, cause); - } - - } - - /** JaCoCo's {@link RT} agent instance */ - private final IAgent agent; - - /** Constructor. */ - public JacocoRuntimeController(IAgent agent) { - this.agent = agent; - } - - /** - * Dumps execution data and resets it. - * - * @throws DumpException if dumping fails. This should never happen in real life. Dumping should simply be retried - * later if this ever happens. - */ - public Dump dumpAndReset() throws DumpException { - byte[] binaryData = agent.getExecutionData(true); - - try (ByteArrayInputStream inputStream = new ByteArrayInputStream(binaryData)) { - ExecutionDataReader reader = new ExecutionDataReader(inputStream); - - ExecutionDataStore store = new ExecutionDataStore(); - reader.setExecutionDataVisitor(store::put); - - SessionInfoVisitor sessionInfoVisitor = new SessionInfoVisitor(); - reader.setSessionInfoVisitor(sessionInfoVisitor); - - reader.read(); - return new Dump(sessionInfoVisitor.sessionInfo, store); - } catch (IOException e) { - throw new DumpException("should never happen for the ByteArrayInputStream", e); - } - } - - /** - * Dumps execution data to the given file and resets it afterwards. - */ - public void dumpToFileAndReset(File file) throws IOException { - byte[] binaryData = agent.getExecutionData(true); - - try (FileOutputStream outputStream = new FileOutputStream(file, true)) { - outputStream.write(binaryData); - } - } - - - /** - * Dumps execution data to a file and resets it. - * - * @throws DumpException if dumping fails. This should never happen in real life. Dumping should simply be retried - * later if this ever happens. - */ - public void dump() throws DumpException { - try { - agent.dump(true); - } catch (IOException e) { - throw new DumpException(e.getMessage(), e); - } - } - - /** Resets already collected coverage. */ - public void reset() { - agent.reset(); - } - - /** Returns the current sessionId. */ - public String getSessionId() { - return agent.getSessionId(); - } - - /** - * Sets the current sessionId of the agent that can be used to identify which coverage is recorded from now on. - */ - public void setSessionId(String sessionId) { - agent.setSessionId(sessionId); - } - - /** Unsets the session ID so that coverage collected from now on is not attributed to the previous test. */ - public void resetSessionId() { - agent.setSessionId(""); - } - - /** - * Receives and stores a {@link SessionInfo}. Has a fallback dummy session in case nothing is received. - */ - private static class SessionInfoVisitor implements ISessionInfoVisitor { - - /** The received session info or a dummy. */ - public SessionInfo sessionInfo = new SessionInfo("dummysession", System.currentTimeMillis(), - System.currentTimeMillis()); - - /** {@inheritDoc} */ - @Override - public void visitSessionInfo(SessionInfo info) { - this.sessionInfo = info; - } - - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/LenientCoverageTransformer.java b/agent/src/main/java/com/teamscale/jacoco/agent/LenientCoverageTransformer.java deleted file mode 100644 index 51c65737b..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/LenientCoverageTransformer.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.teamscale.jacoco.agent; - -import org.jacoco.agent.rt.internal_29a6edd.CoverageTransformer; -import org.jacoco.agent.rt.internal_29a6edd.core.runtime.AgentOptions; -import org.jacoco.agent.rt.internal_29a6edd.core.runtime.IRuntime; -import org.slf4j.Logger; - -import java.lang.instrument.IllegalClassFormatException; -import java.security.ProtectionDomain; - -/** - * A class file transformer which delegates to the JaCoCo {@link CoverageTransformer} to do the actual instrumentation, - * but treats instrumentation errors e.g. due to unsupported class file versions more lenient by only logging them, but - * not bailing out completely. Those unsupported classes will not be instrumented and will therefore not be contained in - * the collected coverage report. - */ -public class LenientCoverageTransformer extends CoverageTransformer { - - private final Logger logger; - - public LenientCoverageTransformer(IRuntime runtime, AgentOptions options, Logger logger) { - // The coverage transformer only uses the logger to print an error when the instrumentation fails. - // We want to show our more specific error message instead, so we only log this for debugging at trace. - super(runtime, options, e -> logger.trace(e.getMessage(), e)); - this.logger = logger; - } - - @Override - public byte[] transform(ClassLoader loader, String classname, Class classBeingRedefined, - ProtectionDomain protectionDomain, - byte[] classfileBuffer) { - try { - return super.transform(loader, classname, classBeingRedefined, protectionDomain, classfileBuffer); - } catch (IllegalClassFormatException e) { - logger.error( - "Failed to instrument " + classname + ". File will be skipped from instrumentation. " + - "No coverage will be collected for it. Exclude the file from the instrumentation or try " + - "updating the Teamscale Java Profiler if the file should actually be instrumented. (Cause: {})", - getRootCauseMessage(e)); - return null; - } - } - - private static String getRootCauseMessage(Throwable e) { - if (e.getCause() != null) { - return getRootCauseMessage(e.getCause()); - } - return e.getMessage(); - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/Main.java b/agent/src/main/java/com/teamscale/jacoco/agent/Main.java deleted file mode 100644 index 20c92dae8..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/Main.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.teamscale.jacoco.agent; - -import com.beust.jcommander.JCommander; -import com.beust.jcommander.JCommander.Builder; -import com.beust.jcommander.Parameter; -import com.beust.jcommander.ParameterException; -import com.teamscale.client.StringUtils; -import com.teamscale.jacoco.agent.commandline.Validator; -import com.teamscale.jacoco.agent.convert.ConvertCommand; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.util.AgentUtils; -import org.jacoco.core.JaCoCo; -import org.slf4j.Logger; - -/** Provides a command line interface for interacting with JaCoCo. */ -public class Main { - - /** The logger. */ - private final Logger logger = LoggingUtils.getLogger(this); - - /** The default arguments that will always be parsed. */ - private final DefaultArguments defaultArguments = new DefaultArguments(); - - /** The arguments for the one-time conversion process. */ - private final ConvertCommand command = new ConvertCommand(); - - /** Entry point. */ - public static void main(String[] args) throws Exception { - new Main().parseCommandLineAndRun(args); - } - - /** - * Parses the given command line arguments. Exits the program or throws an exception if the arguments are not valid. - * Then runs the specified command. - */ - private void parseCommandLineAndRun(String[] args) throws Exception { - Builder builder = createJCommanderBuilder(); - JCommander jCommander = builder.build(); - - try { - jCommander.parse(args); - } catch (ParameterException e) { - handleInvalidCommandLine(jCommander, e.getMessage()); - } - - if (defaultArguments.help) { - System.out.println( - "Teamscale Java Profiler " + AgentUtils.VERSION + " compiled against JaCoCo " + JaCoCo.VERSION); - jCommander.usage(); - return; - } - - Validator validator = command.validate(); - if (!validator.isValid()) { - handleInvalidCommandLine(jCommander, StringUtils.LINE_FEED + validator.getErrorMessage()); - } - - logger.info( - "Starting Teamscale Java Profiler " + AgentUtils.VERSION + " compiled against JaCoCo " + JaCoCo.VERSION); - command.run(); - } - - /** Creates a builder for a {@link JCommander} object. */ - private Builder createJCommanderBuilder() { - return JCommander.newBuilder().programName(Main.class.getName()).addObject(defaultArguments).addObject(command); - } - - /** Shows an informative error and help message. Then exits the program. */ - private static void handleInvalidCommandLine(JCommander jCommander, String message) { - System.err.println("Invalid command line: " + message + StringUtils.LINE_FEED); - jCommander.usage(); - System.exit(1); - } - - /** Default arguments that may always be provided. */ - private static class DefaultArguments { - - /** Shows the help message. */ - @Parameter(names = "--help", help = true, description = "Shows all available command line arguments.") - private boolean help; - - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/PreMain.java b/agent/src/main/java/com/teamscale/jacoco/agent/PreMain.java deleted file mode 100644 index 921a115b8..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/PreMain.java +++ /dev/null @@ -1,305 +0,0 @@ -package com.teamscale.jacoco.agent; - -import com.teamscale.client.FileSystemUtils; -import com.teamscale.client.HttpUtils; -import com.teamscale.client.StringUtils; -import com.teamscale.jacoco.agent.configuration.AgentOptionReceiveException; -import com.teamscale.jacoco.agent.logging.DebugLogDirectoryPropertyDefiner; -import com.teamscale.jacoco.agent.logging.LogDirectoryPropertyDefiner; -import com.teamscale.jacoco.agent.logging.LogToTeamscaleAppender; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.options.AgentOptionParseException; -import com.teamscale.jacoco.agent.options.AgentOptions; -import com.teamscale.jacoco.agent.options.AgentOptionsParser; -import com.teamscale.jacoco.agent.options.FilePatternResolver; -import com.teamscale.jacoco.agent.options.JacocoAgentOptionsBuilder; -import com.teamscale.jacoco.agent.options.TeamscaleCredentials; -import com.teamscale.jacoco.agent.options.TeamscalePropertiesUtils; -import com.teamscale.jacoco.agent.testimpact.TestwiseCoverageAgent; -import com.teamscale.jacoco.agent.upload.UploaderException; -import com.teamscale.jacoco.agent.util.AgentUtils; -import com.teamscale.report.util.ILogger; -import kotlin.Pair; -import org.slf4j.Logger; - -import java.io.File; -import java.io.IOException; -import java.lang.instrument.Instrumentation; -import java.lang.management.ManagementFactory; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.List; -import java.util.Optional; -import java.util.stream.Collectors; - -import static com.teamscale.jacoco.agent.logging.LoggingUtils.getLoggerContext; - -/** Container class for the premain entry point for the agent. */ -public class PreMain { - - private static LoggingUtils.LoggingResources loggingResources = null; - - /** - * System property that we use to prevent this agent from being attached to the same VM twice. This can happen if - * the agent is registered via multiple JVM environment variables and/or the command line at the same time. - */ - private static final String LOCKING_SYSTEM_PROPERTY = "TEAMSCALE_JAVA_PROFILER_ATTACHED"; - - /** - * Environment variable from which to read the config ID to use. This is an ID for a profiler configuration that is - * stored in Teamscale. - */ - private static final String CONFIG_ID_ENVIRONMENT_VARIABLE = "TEAMSCALE_JAVA_PROFILER_CONFIG_ID"; - - /** Environment variable from which to read the config file to use. */ - private static final String CONFIG_FILE_ENVIRONMENT_VARIABLE = "TEAMSCALE_JAVA_PROFILER_CONFIG_FILE"; - - /** Environment variable from which to read the Teamscale access token. */ - private static final String ACCESS_TOKEN_ENVIRONMENT_VARIABLE = "TEAMSCALE_ACCESS_TOKEN"; - - /** - * Entry point for the agent, called by the JVM. - */ - public static void premain(String options, Instrumentation instrumentation) throws Exception { - if (System.getProperty(LOCKING_SYSTEM_PROPERTY) != null) { - return; - } - System.setProperty(LOCKING_SYSTEM_PROPERTY, "true"); - - String environmentConfigId = System.getenv(CONFIG_ID_ENVIRONMENT_VARIABLE); - String environmentConfigFile = System.getenv(CONFIG_FILE_ENVIRONMENT_VARIABLE); - if (StringUtils.isEmpty(options) && environmentConfigId == null && environmentConfigFile == null) { - // profiler was registered globally and no config was set explicitly by the user, thus ignore this process - // and don't profile anything - return; - } - - AgentOptions agentOptions = null; - try { - Pair> parseResult = getAndApplyAgentOptions(options, environmentConfigId, - environmentConfigFile); - agentOptions = parseResult.getFirst(); - - // After parsing everything and configuring logging, we now - // can throw the caught exceptions. - for (Exception exception : parseResult.getSecond()) { - throw exception; - } - } catch (AgentOptionParseException e) { - getLoggerContext().getLogger(PreMain.class).error(e.getMessage(), e); - - // Flush logs to Teamscale, if configured. - closeLoggingResources(); - - // Unregister the profiler from Teamscale. - if (agentOptions != null && agentOptions.configurationViaTeamscale != null) { - agentOptions.configurationViaTeamscale.unregisterProfiler(); - } - - throw e; - } catch (AgentOptionReceiveException e) { - // When Teamscale is not available, we don't want to fail hard to still allow for testing even if no - // coverage is collected (see TS-33237) - return; - } - - Logger logger = LoggingUtils.getLogger(Agent.class); - - logger.info("Teamscale Java profiler version " + AgentUtils.VERSION); - logger.info("Starting JaCoCo's agent"); - JacocoAgentOptionsBuilder agentBuilder = new JacocoAgentOptionsBuilder(agentOptions); - JaCoCoPreMain.premain(agentBuilder.createJacocoAgentOptions(), instrumentation, logger); - - if (agentOptions.configurationViaTeamscale != null) { - agentOptions.configurationViaTeamscale.startHeartbeatThreadAndRegisterShutdownHook(); - } - AgentBase agent = createAgent(agentOptions, instrumentation); - agent.registerShutdownHook(); - } - - private static Pair> getAndApplyAgentOptions(String options, - String environmentConfigId, - String environmentConfigFile) throws AgentOptionParseException, IOException, AgentOptionReceiveException { - - DelayedLogger delayedLogger = new DelayedLogger(); - List javaAgents = ManagementFactory.getRuntimeMXBean().getInputArguments().stream().filter( - s -> s.contains("-javaagent")).collect(Collectors.toList()); - // We allow multiple instances of the teamscale-jacoco-agent as we ensure with the #LOCKING_SYSTEM_PROPERTY to only use it once - List differentAgents = javaAgents.stream() - .filter(javaAgent -> !javaAgent.contains("teamscale-jacoco-agent.jar")).collect( - Collectors.toList()); - - if (!differentAgents.isEmpty()) { - delayedLogger.warn( - "Using multiple java agents could interfere with coverage recording: " + - String.join(", ", differentAgents)); - } - if (!javaAgents.get(0).contains("teamscale-jacoco-agent.jar")) { - delayedLogger.warn("For best results consider registering the Teamscale Java Profiler first."); - } - - TeamscaleCredentials credentials = TeamscalePropertiesUtils.parseCredentials(); - if (credentials == null) { - // As many users still don't use the installer based setup, this log message will be shown in almost every log. - // We use a debug log, as this message can be confusing for customers that think a teamscale.properties file is synonymous with a config file. - delayedLogger.debug( - "No explicit teamscale.properties file given. Looking for Teamscale credentials in a config file or via a command line argument. This is expected unless the installer based setup was used."); - } - - String environmentAccessToken = System.getenv(ACCESS_TOKEN_ENVIRONMENT_VARIABLE); - - Pair> parseResult; - AgentOptions agentOptions; - try { - parseResult = AgentOptionsParser.parse( - options, environmentConfigId, environmentConfigFile, credentials, environmentAccessToken, - delayedLogger); - agentOptions = parseResult.getFirst(); - } catch (AgentOptionParseException e) { - try (LoggingUtils.LoggingResources ignored = initializeFallbackLogging(options, delayedLogger)) { - delayedLogger.errorAndStdErr("Failed to parse agent options: " + e.getMessage(), e); - attemptLogAndThrow(delayedLogger); - throw e; - } - } catch (AgentOptionReceiveException e) { - try (LoggingUtils.LoggingResources ignored = initializeFallbackLogging(options, delayedLogger)) { - delayedLogger.errorAndStdErr( - e.getMessage() + " The application should start up normally, but NO coverage will be collected! Check the log file for details.", - e); - attemptLogAndThrow(delayedLogger); - throw e; - } - } - - initializeLogging(agentOptions, delayedLogger); - Logger logger = LoggingUtils.getLogger(Agent.class); - delayedLogger.logTo(logger); - HttpUtils.setShouldValidateSsl(agentOptions.shouldValidateSsl()); - - return parseResult; - } - - private static void attemptLogAndThrow(DelayedLogger delayedLogger) { - // We perform actual logging output after writing to console to - // ensure the console is reached even in case of logging issues - // (see TS-23151). We use the Agent class here (same as below) - Logger logger = LoggingUtils.getLogger(Agent.class); - delayedLogger.logTo(logger); - } - - /** Initializes logging during {@link #premain(String, Instrumentation)} and also logs the log directory. */ - private static void initializeLogging(AgentOptions agentOptions, DelayedLogger logger) throws IOException { - if (agentOptions.isDebugLogging()) { - initializeDebugLogging(agentOptions, logger); - } else { - loggingResources = LoggingUtils.initializeLogging(agentOptions.getLoggingConfig()); - logger.info("Logging to " + new LogDirectoryPropertyDefiner().getPropertyValue()); - } - - if (agentOptions.getTeamscaleServerOptions().isConfiguredForServerConnection()) { - if (LogToTeamscaleAppender.addTeamscaleAppenderTo(getLoggerContext(), agentOptions)) { - logger.info("Logs are being forwarded to Teamscale at " + agentOptions.getTeamscaleServerOptions().url); - } - } - } - - /** Closes the opened logging contexts. */ - static void closeLoggingResources() { - loggingResources.close(); - } - - /** - * Returns in instance of the agent that was configured. Either an agent with interval based line-coverage dump or - * the HTTP server is used. - */ - private static AgentBase createAgent(AgentOptions agentOptions, - Instrumentation instrumentation) throws UploaderException, IOException { - if (agentOptions.useTestwiseCoverageMode()) { - return TestwiseCoverageAgent.create(agentOptions); - } else { - return new Agent(agentOptions, instrumentation); - } - } - - /** - * Initializes debug logging during {@link #premain(String, Instrumentation)} and also logs the log directory if - * given. - */ - private static void initializeDebugLogging(AgentOptions agentOptions, DelayedLogger logger) { - loggingResources = LoggingUtils.initializeDebugLogging(agentOptions.getDebugLogDirectory()); - Path logDirectory = Paths.get(new DebugLogDirectoryPropertyDefiner().getPropertyValue()); - if (FileSystemUtils.isValidPath(logDirectory.toString()) && Files.isWritable(logDirectory)) { - logger.info("Logging to " + logDirectory); - } else { - logger.warn("Could not create " + logDirectory + ". Logging to console only."); - } - } - - /** - * Initializes fallback logging in case of an error during the parsing of the options to - * {@link #premain(String, Instrumentation)} (see TS-23151). This tries to extract the logging configuration and use - * this and falls back to the default logger. - */ - private static LoggingUtils.LoggingResources initializeFallbackLogging(String premainOptions, - DelayedLogger delayedLogger) { - if (premainOptions == null) { - return LoggingUtils.initializeDefaultLogging(); - } - for (String optionPart : premainOptions.split(",")) { - if (optionPart.startsWith(AgentOptionsParser.DEBUG + "=")) { - String value = optionPart.split("=", 2)[1]; - boolean debugDisabled = value.equalsIgnoreCase("false"); - boolean debugEnabled = value.equalsIgnoreCase("true"); - if (debugDisabled) { - continue; - } - Path debugLogDirectory = null; - if (!value.isEmpty() && !debugEnabled) { - debugLogDirectory = Paths.get(value); - } - return LoggingUtils.initializeDebugLogging(debugLogDirectory); - } - if (optionPart.startsWith(AgentOptionsParser.LOGGING_CONFIG_OPTION + "=")) { - return createFallbackLoggerFromConfig(optionPart.split("=", 2)[1], delayedLogger); - } - - if (optionPart.startsWith(AgentOptionsParser.CONFIG_FILE_OPTION + "=")) { - String configFileValue = optionPart.split("=", 2)[1]; - Optional loggingConfigLine = Optional.empty(); - try { - File configFile = new FilePatternResolver(delayedLogger).parsePath( - AgentOptionsParser.CONFIG_FILE_OPTION, configFileValue).toFile(); - loggingConfigLine = FileSystemUtils.readLinesUTF8(configFile).stream() - .filter(line -> line.startsWith(AgentOptionsParser.LOGGING_CONFIG_OPTION + "=")) - .findFirst(); - } catch (IOException e) { - delayedLogger.error("Failed to load configuration from " + configFileValue + ": " + e.getMessage(), - e); - } - if (loggingConfigLine.isPresent()) { - return createFallbackLoggerFromConfig(loggingConfigLine.get().split("=", 2)[1], delayedLogger); - } - } - } - - return LoggingUtils.initializeDefaultLogging(); - } - - /** Creates a fallback logger using the given config file. */ - private static LoggingUtils.LoggingResources createFallbackLoggerFromConfig(String configLocation, - ILogger delayedLogger) { - try { - return LoggingUtils.initializeLogging( - new FilePatternResolver(delayedLogger).parsePath(AgentOptionsParser.LOGGING_CONFIG_OPTION, - configLocation)); - } catch (IOException e) { - String message = "Failed to load log configuration from location " + configLocation + ": " + e.getMessage(); - delayedLogger.error(message, e); - // output the message to console as well, as this might - // otherwise not make it to the user - System.err.println(message); - return LoggingUtils.initializeDefaultLogging(); - } - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/ResourceBase.java b/agent/src/main/java/com/teamscale/jacoco/agent/ResourceBase.java deleted file mode 100644 index fb539824e..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/ResourceBase.java +++ /dev/null @@ -1,145 +0,0 @@ -package com.teamscale.jacoco.agent; - -import com.teamscale.client.CommitDescriptor; -import com.teamscale.client.StringUtils; -import com.teamscale.client.TeamscaleServer; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.testimpact.TestwiseCoverageAgent; -import com.teamscale.report.testwise.model.RevisionInfo; -import org.jetbrains.annotations.Contract; -import org.slf4j.Logger; - -import javax.ws.rs.BadRequestException; -import javax.ws.rs.GET; -import javax.ws.rs.PUT; -import javax.ws.rs.Path; -import javax.ws.rs.Produces; -import javax.ws.rs.core.MediaType; -import javax.ws.rs.core.Response; -import java.util.Optional; - - -/** - * The resource of the Jersey + Jetty http server holding all the endpoints specific for the {@link AgentBase}. - */ -public abstract class ResourceBase { - - /** The logger. */ - protected final Logger logger = LoggingUtils.getLogger(this); - - /** - * The agentBase inject via {@link AgentResource#setAgent(Agent)} or - * {@link com.teamscale.jacoco.agent.testimpact.TestwiseCoverageResource#setAgent(TestwiseCoverageAgent)}. - */ - protected static AgentBase agentBase; - - /** Returns the partition for the Teamscale upload. */ - @GET - @Path("/partition") - public String getPartition() { - return Optional.ofNullable(agentBase.options.getTeamscaleServerOptions().partition).orElse(""); - } - - /** Returns the upload message for the Teamscale upload. */ - @GET - @Path("/message") - public String getMessage() { - return Optional.ofNullable(agentBase.options.getTeamscaleServerOptions().getMessage()) - .orElse(""); - } - - /** Returns revision information for the Teamscale upload. */ - @GET - @Path("/revision") - @Produces(MediaType.APPLICATION_JSON) - public RevisionInfo getRevision() { - return this.getRevisionInfo(); - } - - /** Returns revision information for the Teamscale upload. */ - @GET - @Path("/commit") - @Produces(MediaType.APPLICATION_JSON) - public RevisionInfo getCommit() { - return this.getRevisionInfo(); - } - - /** Handles setting the partition name. */ - @PUT - @Path("/partition") - public Response setPartition(String partitionString) { - String partition = StringUtils.removeDoubleQuotes(partitionString); - if (partition == null || partition.isEmpty()) { - handleBadRequest("The new partition name is missing in the request body! Please add it as plain text."); - } - - logger.debug("Changing partition name to " + partition); - agentBase.dumpReport(); - agentBase.controller.setSessionId(partition); - agentBase.options.getTeamscaleServerOptions().partition = partition; - return Response.noContent().build(); - } - - /** Handles setting the upload message. */ - @PUT - @Path("/message") - public Response setMessage(String messageString) { - String message = StringUtils.removeDoubleQuotes(messageString); - if (message == null || message.isEmpty()) { - handleBadRequest("The new message is missing in the request body! Please add it as plain text."); - } - - agentBase.dumpReport(); - logger.debug("Changing message to " + message); - agentBase.options.getTeamscaleServerOptions().setMessage(message); - - return Response.noContent().build(); - } - - /** Handles setting the revision. */ - @PUT - @Path("/revision") - public Response setRevision(String revisionString) { - String revision = StringUtils.removeDoubleQuotes(revisionString); - if (revision == null || revision.isEmpty()) { - handleBadRequest("The new revision name is missing in the request body! Please add it as plain text."); - } - - agentBase.dumpReport(); - logger.debug("Changing revision name to " + revision); - agentBase.options.getTeamscaleServerOptions().revision = revision; - - return Response.noContent().build(); - } - - /** Handles setting the upload commit. */ - @PUT - @Path("/commit") - public Response setCommit(String commitString) { - String commit = StringUtils.removeDoubleQuotes(commitString); - if (commit == null || commit.isEmpty()) { - handleBadRequest("The new upload commit is missing in the request body! Please add it as plain text."); - } - - agentBase.dumpReport(); - agentBase.options.getTeamscaleServerOptions().commit = CommitDescriptor.parse(commit); - - return Response.noContent().build(); - } - - /** Returns revision information for the Teamscale upload. */ - private RevisionInfo getRevisionInfo() { - TeamscaleServer server = agentBase.options.getTeamscaleServerOptions(); - return new RevisionInfo(server.commit, server.revision); - } - - /** - * Handles bad requests to the endpoints. - */ - @Contract(value = "_ -> fail") - protected void handleBadRequest(String message) throws BadRequestException { - logger.error(message); - throw new BadRequestException(message); - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commandline/Validator.java b/agent/src/main/java/com/teamscale/jacoco/agent/commandline/Validator.java deleted file mode 100644 index 40b7ce388..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/commandline/Validator.java +++ /dev/null @@ -1,68 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2017 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent.commandline; - -import com.teamscale.client.StringUtils; -import com.teamscale.jacoco.agent.util.Assertions; - -import java.util.ArrayList; -import java.util.List; - -/** - * Helper class to allow for multiple validations to occur. - */ -public class Validator { - - /** The found validation problems in the form of error messages for the user. */ - private final List messages = new ArrayList<>(); - - /** Runs the given validation routine. */ - public void ensure(ExceptionBasedValidation validation) { - try { - validation.validate(); - } catch (Exception | AssertionError e) { - messages.add(e.getMessage()); - } - } - - /** - * Interface for a validation routine that throws an exception when it fails. - */ - @FunctionalInterface - public interface ExceptionBasedValidation { - - /** - * Throws an {@link Exception} or {@link AssertionError} if the validation fails. - */ - void validate() throws Exception, AssertionError; - - } - - /** - * Checks that the given condition is true or adds the given error message. - */ - public void isTrue(boolean condition, String message) { - ensure(() -> Assertions.isTrue(condition, message)); - } - - /** - * Checks that the given condition is false or adds the given error message. - */ - public void isFalse(boolean condition, String message) { - ensure(() -> Assertions.isFalse(condition, message)); - } - - /** Returns true if the validation succeeded. */ - public boolean isValid() { - return messages.isEmpty(); - } - - /** Returns an error message with all validation problems that were found. */ - public String getErrorMessage() { - return "- " + String.join(StringUtils.LINE_FEED + "- ", messages); - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/configuration/AgentOptionReceiveException.java b/agent/src/main/java/com/teamscale/jacoco/agent/configuration/AgentOptionReceiveException.java deleted file mode 100644 index 80f9bb19d..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/configuration/AgentOptionReceiveException.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.teamscale.jacoco.agent.configuration; - -/** Thrown when retrieving the profiler configuration from Teamscale fails. */ -public class AgentOptionReceiveException extends Exception { - - /** - * Serialization ID. - */ - private static final long serialVersionUID = 1L; - - /** - * Constructor. - */ - public AgentOptionReceiveException(String message) { - super(message); - } - - /** - * Constructor. - */ - public AgentOptionReceiveException(String message, Throwable cause) { - super(message, cause); - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.java b/agent/src/main/java/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.java deleted file mode 100644 index d4e59a7e6..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.java +++ /dev/null @@ -1,168 +0,0 @@ -package com.teamscale.jacoco.agent.configuration; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.teamscale.client.ITeamscaleService; -import com.teamscale.client.JsonUtils; -import com.teamscale.client.ProcessInformation; -import com.teamscale.client.ProfilerConfiguration; -import com.teamscale.client.ProfilerInfo; -import com.teamscale.client.ProfilerRegistration; -import com.teamscale.client.TeamscaleServiceGenerator; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.util.AgentUtils; -import com.teamscale.report.util.ILogger; -import okhttp3.HttpUrl; -import okhttp3.ResponseBody; -import org.jetbrains.annotations.NotNull; -import retrofit2.Response; - -import java.io.IOException; -import java.time.Duration; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -/** - * Responsible for holding the configuration that was retrieved from Teamscale and sending regular heartbeat events to - * keep the profiler information in Teamscale up to date. - */ -public class ConfigurationViaTeamscale { - - /** - * Two minute timeout. This is quite high to account for an eventual high load on the Teamscale server. This is a - * tradeoff between fast application startup and potentially missing test coverage. - */ - private static final Duration LONG_TIMEOUT = Duration.ofMinutes(2); - - /** - * The UUID that Teamscale assigned to this instance of the profiler during the registration. This ID needs to be - * used when communicating with Teamscale. - */ - private final String profilerId; - - private final ITeamscaleService teamscaleClient; - private final ProfilerInfo profilerInfo; - - public ConfigurationViaTeamscale(ITeamscaleService teamscaleClient, ProfilerRegistration profilerRegistration, - ProcessInformation processInformation) { - this.teamscaleClient = teamscaleClient; - this.profilerId = profilerRegistration.profilerId; - this.profilerInfo = new ProfilerInfo(processInformation, profilerRegistration.profilerConfiguration); - } - - /** - * Tries to retrieve the profiler configuration from Teamscale. In case retrieval fails the method throws a - * {@link AgentOptionReceiveException}. - */ - public static @NotNull ConfigurationViaTeamscale retrieve(ILogger logger, String configurationId, HttpUrl url, - String userName, - String userAccessToken) throws AgentOptionReceiveException { - ITeamscaleService teamscaleClient = TeamscaleServiceGenerator - .createService(ITeamscaleService.class, url, userName, userAccessToken, AgentUtils.USER_AGENT, - LONG_TIMEOUT, LONG_TIMEOUT); - try { - ProcessInformation processInformation = new ProcessInformationRetriever(logger).getProcessInformation(); - Response response = teamscaleClient.registerProfiler(configurationId, - processInformation).execute(); - if (!response.isSuccessful()) { - throw new AgentOptionReceiveException( - "Failed to retrieve profiler configuration from Teamscale due to failed request. Http status: " + response.code() - + " Body: " + response.errorBody().string()); - } - - ResponseBody body = response.body(); - return parseProfilerRegistration(body, response, teamscaleClient, processInformation); - } catch (IOException e) { - // we include the causing error message in this exception's message since this causes it to be printed - // to stderr which is much more helpful than just saying "something didn't work" - throw new AgentOptionReceiveException( - "Failed to retrieve profiler configuration from Teamscale due to network error: " + LoggingUtils.getStackTraceAsString( - e), - e); - } - } - - private static @NotNull ConfigurationViaTeamscale parseProfilerRegistration(ResponseBody body, - Response response, ITeamscaleService teamscaleClient, - ProcessInformation processInformation) throws AgentOptionReceiveException, IOException { - if (body == null) { - throw new AgentOptionReceiveException( - "Failed to retrieve profiler configuration from Teamscale due to empty response. HTTP code: " + response.code()); - } - // We may only call this once - String bodyString = body.string(); - try { - ProfilerRegistration registration = JsonUtils.deserialize(bodyString, - ProfilerRegistration.class); - if (registration == null) { - throw new AgentOptionReceiveException( - "Failed to retrieve profiler configuration from Teamscale due to invalid JSON. HTTP code: " + response.code() + " Response: " + bodyString); - } - return new ConfigurationViaTeamscale(teamscaleClient, registration, processInformation); - } catch (JsonProcessingException e) { - throw new AgentOptionReceiveException( - "Failed to retrieve profiler configuration from Teamscale due to invalid JSON. HTTP code: " + response.code() + " Response: " + bodyString, - e); - } - } - - /** Returns the profiler configuration that was retrieved from Teamscale. */ - public ProfilerConfiguration getProfilerConfiguration() { - return profilerInfo.profilerConfiguration; - } - - - /** - * Starts a heartbeat thread and registers a shutdown hook. - *

- * This spawns a new thread every minute which sends a heartbeat to Teamscale. It also registers a shutdown hook - * that unregisters the profiler from Teamscale. - */ - public void startHeartbeatThreadAndRegisterShutdownHook() { - ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(runnable -> { - Thread thread = new Thread(runnable); - thread.setDaemon(true); - return thread; - }); - - executor.scheduleAtFixedRate(this::sendHeartbeat, 1, 1, TimeUnit.MINUTES); - - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - executor.shutdownNow(); - unregisterProfiler(); - })); - } - - private void sendHeartbeat() { - try { - Response response = teamscaleClient.sendHeartbeat(profilerId, profilerInfo).execute(); - if (!response.isSuccessful()) { - LoggingUtils.getLogger(this) - .error("Failed to send heartbeat. Teamscale responded with: " + response.errorBody().string()); - } - } catch (IOException e) { - LoggingUtils.getLogger(this).error("Failed to send heartbeat to Teamscale!", e); - } - } - - /** Unregisters the profiler in Teamscale (marks it as shut down). */ - public void unregisterProfiler() { - try { - Response response = teamscaleClient.unregisterProfiler(profilerId).execute(); - if (response.code() == 405) { - response = teamscaleClient.unregisterProfilerLegacy(profilerId).execute(); - } - if (!response.isSuccessful()) { - LoggingUtils.getLogger(this) - .error("Failed to unregister profiler. Teamscale responded with: " + response.errorBody() - .string()); - } - } catch (IOException e) { - LoggingUtils.getLogger(this).error("Failed to unregister profiler!", e); - } - } - - public String getProfilerId() { - return profilerId; - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/configuration/ProcessInformationRetriever.java b/agent/src/main/java/com/teamscale/jacoco/agent/configuration/ProcessInformationRetriever.java deleted file mode 100644 index 63a34f4cf..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/configuration/ProcessInformationRetriever.java +++ /dev/null @@ -1,64 +0,0 @@ -package com.teamscale.jacoco.agent.configuration; - -import com.teamscale.client.ProcessInformation; -import com.teamscale.report.util.ILogger; - -import java.lang.management.ManagementFactory; -import java.lang.reflect.InvocationTargetException; -import java.net.InetAddress; -import java.net.UnknownHostException; - -/** - * Is responsible for retrieving process information such as the host name and process ID. - */ -public class ProcessInformationRetriever { - - private final ILogger logger; - - public ProcessInformationRetriever(ILogger logger) { - this.logger = logger; - } - - /** - * Retrieves the process information, including the host name and process ID. - */ - public ProcessInformation getProcessInformation() { - String hostName = getHostName(); - String processId = getPID(); - return new ProcessInformation(hostName, processId, System.currentTimeMillis()); - } - - /** - * Retrieves the host name of the local machine. - */ - private String getHostName() { - try { - InetAddress inetAddress = InetAddress.getLocalHost(); - return inetAddress.getHostName(); - } catch (UnknownHostException e) { - logger.error("Failed to determine hostname!", e); - return ""; - } - } - - /** - * Returns a string that probably contains the PID. - *

- * On Java 9 there is an API to get the PID. But since we support Java 8, we may fall back to an undocumented API - * that at least contains the PID in most JVMs. - *

- * See This - * StackOverflow question - */ - public static String getPID() { - try { - Class processHandleClass = Class.forName("java.lang.ProcessHandle"); - Object processHandle = processHandleClass.getMethod("current").invoke(null); - Long pid = (Long) processHandleClass.getMethod("pid").invoke(processHandle); - return pid.toString(); - } catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException | - InvocationTargetException e) { - return ManagementFactory.getRuntimeMXBean().getName(); - } - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/convert/ConvertCommand.java b/agent/src/main/java/com/teamscale/jacoco/agent/convert/ConvertCommand.java deleted file mode 100644 index 116c5fe44..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/convert/ConvertCommand.java +++ /dev/null @@ -1,171 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2017 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent.convert; - -import com.beust.jcommander.JCommander; -import com.beust.jcommander.Parameter; -import com.beust.jcommander.Parameters; -import com.teamscale.client.FileSystemUtils; -import com.teamscale.client.StringUtils; -import com.teamscale.jacoco.agent.commandline.ICommand; -import com.teamscale.jacoco.agent.commandline.Validator; -import com.teamscale.jacoco.agent.options.ClasspathUtils; -import com.teamscale.jacoco.agent.options.FilePatternResolver; -import com.teamscale.jacoco.agent.util.Assertions; -import com.teamscale.report.EDuplicateClassFileBehavior; -import com.teamscale.report.util.CommandLineLogger; - -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.stream.Collectors; - -/** - * Encapsulates all command line options for the convert command for parsing with {@link JCommander}. - */ -@Parameters(commandNames = "convert", commandDescription = "Converts a binary .exec coverage file to XML. " + - "Note that the XML report will only contain source file coverage information, but no class coverage.") -public class ConvertCommand implements ICommand { - - /** The directories and/or zips that contain all class files being profiled. */ - @Parameter(names = {"--class-dir", "--jar", "-c"}, required = true, description = "" - + "The directories or zip/ear/jar/war/... files that contain the compiled Java classes being profiled." - + " Searches recursively, including inside zips. You may also supply a *.txt file with one path per line.") - /* package */ List classDirectoriesOrZips = new ArrayList<>(); - - /** - * Wildcard include patterns to apply during JaCoCo's traversal of class files. - */ - @Parameter(names = {"--includes"}, description = "" - + "Wildcard include patterns to apply to all found class file locations during JaCoCo's traversal of class files." - + " Note that zip contents are separated from zip files with @ and that you can filter only" - + " class files, not intermediate folders/zips. Use with great care as missing class files" - + " lead to broken coverage files! Turn on debug logging to see which locations are being filtered." - + " Defaults to no filtering. Excludes overrule includes.") - /* package */ List locationIncludeFilters = new ArrayList<>(); - - /** - * Wildcard exclude patterns to apply during JaCoCo's traversal of class files. - */ - @Parameter(names = {"--excludes", "-e"}, description = "" - + "Wildcard exclude patterns to apply to all found class file locations during JaCoCo's traversal of class files." - + " Note that zip contents are separated from zip files with @ and that you can filter only" - + " class files, not intermediate folders/zips. Use with great care as missing class files" - + " lead to broken coverage files! Turn on debug logging to see which locations are being filtered." - + " Defaults to no filtering. Excludes overrule includes.") - /* package */ List locationExcludeFilters = new ArrayList<>(); - - /** The directory to write the XML traces to. */ - @Parameter(names = {"--in", "-i"}, required = true, description = "" + "The binary .exec file(s), test details and " + - "test executions to read. Can be a single file or a directory that is recursively scanned for relevant files.") - /* package */ List inputFiles = new ArrayList<>(); - - /** The directory to write the XML traces to. */ - @Parameter(names = {"--out", "-o"}, required = true, description = "" - + "The file to write the generated XML report to.") - /* package */ String outputFile = ""; - - /** Whether to ignore duplicate, non-identical class files. */ - @Parameter(names = {"--duplicates", "-d"}, arity = 1, description = "" - + "Whether to ignore duplicate, non-identical class files." - + " This is discouraged and may result in incorrect coverage files. Defaults to WARN. " + - "Options are FAIL, WARN and IGNORE.") - /* package */ EDuplicateClassFileBehavior duplicateClassFileBehavior = EDuplicateClassFileBehavior.WARN; - - /** Whether to ignore uncovered class files. */ - @Parameter(names = {"--ignore-uncovered-classes"}, required = false, arity = 1, description = "" - + "Whether to ignore uncovered classes." - + " These classes will not be part of the XML report at all, making it considerably smaller in some cases. Defaults to false.") - /* package */ boolean shouldIgnoreUncoveredClasses = false; - - /** Whether testwise coverage or jacoco coverage should be generated. */ - @Parameter(names = {"--testwise-coverage", "-t"}, required = false, arity = 0, description = "Whether testwise " + - "coverage or jacoco coverage should be generated.") - /* package */ boolean shouldGenerateTestwiseCoverage = false; - - /** After how many tests testwise coverage should be split into multiple reports. */ - @Parameter(names = {"--split-after", "-s"}, required = false, arity = 1, description = "After how many tests " + - "testwise coverage should be split into multiple reports (Default is 5000).") - private int splitAfter = 5000; - - /** @see #classDirectoriesOrZips */ - public List getClassDirectoriesOrZips() throws IOException { - return ClasspathUtils - .resolveClasspathTextFiles("class-dir", new FilePatternResolver(new CommandLineLogger()), - classDirectoriesOrZips); - } - - /** @see #locationIncludeFilters */ - public List getLocationIncludeFilters() { - return locationIncludeFilters; - } - - /** @see #locationExcludeFilters */ - public List getLocationExcludeFilters() { - return locationExcludeFilters; - } - - /** @see #inputFiles */ - public List getInputFiles() { - return inputFiles.stream().map(File::new).collect(Collectors.toList()); - } - - /** @see #outputFile */ - public File getOutputFile() { - return new File(outputFile); - } - - /** @see #splitAfter */ - public int getSplitAfter() { - return splitAfter; - } - - /** @see #duplicateClassFileBehavior */ - public EDuplicateClassFileBehavior getDuplicateClassFileBehavior() { - return duplicateClassFileBehavior; - } - - /** Makes sure the arguments are valid. */ - @Override - public Validator validate() { - Validator validator = new Validator(); - - List classDirectoriesOrZips = new ArrayList<>(); - validator.ensure(() -> classDirectoriesOrZips.addAll(getClassDirectoriesOrZips())); - validator.isFalse(classDirectoriesOrZips.isEmpty(), - "You must specify at least one directory or zip that contains class files"); - for (File path : classDirectoriesOrZips) { - validator.isTrue(path.exists(), "Path '" + path + "' does not exist"); - validator.isTrue(path.canRead(), "Path '" + path + "' is not readable"); - } - - for (File inputFile : getInputFiles()) { - validator.isTrue(inputFile.exists() && inputFile.canRead(), - "Cannot read the input file " + inputFile); - } - - validator.ensure(() -> { - Assertions.isFalse(StringUtils.isEmpty(outputFile), "You must specify an output file"); - File outputDir = getOutputFile().getAbsoluteFile().getParentFile(); - FileSystemUtils.ensureDirectoryExists(outputDir); - Assertions.isTrue(outputDir.canWrite(), "Path '" + outputDir + "' is not writable"); - }); - - return validator; - } - - /** {@inheritDoc} */ - @Override - public void run() throws Exception { - Converter converter = new Converter(this); - if (this.shouldGenerateTestwiseCoverage) { - converter.runTestwiseCoverageReportGeneration(); - } else { - converter.runJaCoCoReportGeneration(); - } - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/convert/Converter.java b/agent/src/main/java/com/teamscale/jacoco/agent/convert/Converter.java deleted file mode 100644 index e46cc5852..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/convert/Converter.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.teamscale.jacoco.agent.convert; - -import com.teamscale.client.TestDetails; -import com.teamscale.jacoco.agent.logging.LoggingUtils; -import com.teamscale.jacoco.agent.options.AgentOptionParseException; -import com.teamscale.jacoco.agent.util.Benchmark; -import com.teamscale.report.ReportUtils; -import com.teamscale.report.jacoco.EmptyReportException; -import com.teamscale.report.jacoco.JaCoCoXmlReportGenerator; -import com.teamscale.report.testwise.ETestArtifactFormat; -import com.teamscale.report.testwise.TestwiseCoverageReportWriter; -import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator; -import com.teamscale.report.testwise.model.TestExecution; -import com.teamscale.report.testwise.model.factory.TestInfoFactory; -import com.teamscale.report.util.ClasspathWildcardIncludeFilter; -import com.teamscale.report.util.CommandLineLogger; -import com.teamscale.report.util.ILogger; -import org.slf4j.Logger; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Paths; -import java.util.List; - -import static com.teamscale.jacoco.agent.logging.LoggingUtils.wrap; - -/** Converts one .exec binary coverage file to XML. */ -public class Converter { - - /** The command line arguments. */ - private ConvertCommand arguments; - - /** Constructor. */ - public Converter(ConvertCommand arguments) { - this.arguments = arguments; - } - - /** Converts one .exec binary coverage file to XML. */ - public void runJaCoCoReportGeneration() throws IOException { - List jacocoExecutionDataList = ReportUtils - .listFiles(ETestArtifactFormat.JACOCO, arguments.getInputFiles()); - - Logger logger = LoggingUtils.getLogger(this); - JaCoCoXmlReportGenerator generator = new JaCoCoXmlReportGenerator(arguments.getClassDirectoriesOrZips(), - getWildcardIncludeExcludeFilter(), arguments.getDuplicateClassFileBehavior(), - arguments.shouldIgnoreUncoveredClasses, - wrap(logger)); - - try (Benchmark benchmark = new Benchmark("Generating the XML report")) { - generator.convertExecFilesToReport(jacocoExecutionDataList, Paths.get(arguments.outputFile).toFile()); - } catch (EmptyReportException e) { - logger.warn("Converted report was empty.", e); - } - } - - /** Converts one .exec binary coverage file, test details and test execution files to JSON testwise coverage. */ - public void runTestwiseCoverageReportGeneration() throws IOException, AgentOptionParseException { - List testDetails = ReportUtils.readObjects(ETestArtifactFormat.TEST_LIST, - TestDetails[].class, arguments.getInputFiles()); - List testExecutions = ReportUtils.readObjects(ETestArtifactFormat.TEST_EXECUTION, - TestExecution[].class, arguments.getInputFiles()); - - List jacocoExecutionDataList = ReportUtils - .listFiles(ETestArtifactFormat.JACOCO, arguments.getInputFiles()); - ILogger logger = new CommandLineLogger(); - - JaCoCoTestwiseReportGenerator generator = new JaCoCoTestwiseReportGenerator( - arguments.getClassDirectoriesOrZips(), - getWildcardIncludeExcludeFilter(), - arguments.getDuplicateClassFileBehavior(), - logger - ); - - TestInfoFactory testInfoFactory = new TestInfoFactory(testDetails, testExecutions); - - try (Benchmark benchmark = new Benchmark("Generating the testwise coverage report")) { - logger.info( - "Writing report with " + testDetails.size() + " Details/" + testExecutions.size() + " Results"); - - try (TestwiseCoverageReportWriter coverageWriter = new TestwiseCoverageReportWriter(testInfoFactory, - arguments.getOutputFile(), arguments.getSplitAfter(), null)) { - for (File executionDataFile : jacocoExecutionDataList) { - generator.convertAndConsume(executionDataFile, coverageWriter); - } - } - } - } - - private ClasspathWildcardIncludeFilter getWildcardIncludeExcludeFilter() { - return new ClasspathWildcardIncludeFilter( - String.join(":", arguments.getLocationIncludeFilters()), - String.join(":", arguments.getLocationExcludeFilters())); - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/logging/DebugLogDirectoryPropertyDefiner.java b/agent/src/main/java/com/teamscale/jacoco/agent/logging/DebugLogDirectoryPropertyDefiner.java deleted file mode 100644 index ce83d7422..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/logging/DebugLogDirectoryPropertyDefiner.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.teamscale.jacoco.agent.logging; - -import java.nio.file.Path; - -/** Defines a property that contains the path to which log files should be written. */ -public class DebugLogDirectoryPropertyDefiner extends LogDirectoryPropertyDefiner { - - /** File path for debug logging. */ - /* package */ static Path filePath = null; - - @Override - public String getPropertyValue() { - if (filePath == null) { - return super.getPropertyValue(); - } - return filePath.resolve("logs").toAbsolutePath().toString(); - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/logging/LogDirectoryPropertyDefiner.java b/agent/src/main/java/com/teamscale/jacoco/agent/logging/LogDirectoryPropertyDefiner.java deleted file mode 100644 index a10c57221..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/logging/LogDirectoryPropertyDefiner.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.teamscale.jacoco.agent.logging; - -import ch.qos.logback.core.PropertyDefinerBase; -import com.teamscale.jacoco.agent.util.AgentUtils; - -import java.nio.file.Path; - -/** Defines a property that contains the default path to which log files should be written. */ -public class LogDirectoryPropertyDefiner extends PropertyDefinerBase { - @Override - public String getPropertyValue() { - Path tempDirectory = AgentUtils.getMainTempDirectory(); - return tempDirectory.resolve("logs").toAbsolutePath().toString(); - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.java b/agent/src/main/java/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.java deleted file mode 100644 index 4a1906388..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.java +++ /dev/null @@ -1,207 +0,0 @@ -package com.teamscale.jacoco.agent.logging; - -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.LoggerContext; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.AppenderBase; -import ch.qos.logback.core.status.ErrorStatus; -import com.teamscale.client.ITeamscaleService; -import com.teamscale.client.ProfilerLogEntry; -import com.teamscale.client.TeamscaleClient; -import com.teamscale.jacoco.agent.options.AgentOptions; -import org.jetbrains.annotations.Nullable; -import retrofit2.Call; - -import java.net.ConnectException; -import java.time.Duration; -import java.util.ArrayList; -import java.util.Collections; -import java.util.IdentityHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicBoolean; - -import static com.teamscale.jacoco.agent.logging.LoggingUtils.getStackTraceFromEvent; - -/** - * Custom log appender that sends logs to Teamscale; it buffers log that were not sent due to connection issues and - * sends them later. - */ -public class LogToTeamscaleAppender extends AppenderBase { - - /** Flush the logs after N elements are in the queue */ - private static final int BATCH_SIZE = 50; - - /** Flush the logs in the given time interval */ - private static final Duration FLUSH_INTERVAL = Duration.ofSeconds(3); - - /** The unique ID of the profiler */ - private String profilerId; - - /** The service client for sending logs to Teamscale */ - private static ITeamscaleService teamscaleClient; - - /** - * Buffer for unsent logs. We use a set here to allow for removing entries fast after sending them to Teamscale was - * successful. - */ - private final LinkedHashSet logBuffer = new LinkedHashSet<>(); - - /** Scheduler for sending logs after the configured time interval */ - private final ScheduledExecutorService scheduler; - - /** Active log flushing threads */ - private final Set> activeLogFlushes = Collections.newSetFromMap(new IdentityHashMap<>()); - - /** Is there a flush going on right now? */ - private final AtomicBoolean isFlusing = new AtomicBoolean(false); - - public LogToTeamscaleAppender() { - this.scheduler = Executors.newScheduledThreadPool(1, r -> { - // Make the thread a daemon so that it does not prevent the JVM from terminating. - Thread t = Executors.defaultThreadFactory().newThread(r); - t.setDaemon(true); - return t; - }); - } - - @Override - public void start() { - super.start(); - scheduler.scheduleAtFixedRate(() -> { - synchronized (activeLogFlushes) { - activeLogFlushes.removeIf(CompletableFuture::isDone); - if (this.activeLogFlushes.isEmpty()) { - flush(); - } - } - }, FLUSH_INTERVAL.toMillis(), FLUSH_INTERVAL.toMillis(), TimeUnit.MILLISECONDS); - } - - @Override - protected void append(ILoggingEvent eventObject) { - synchronized (logBuffer) { - logBuffer.add(formatLog(eventObject)); - if (logBuffer.size() >= BATCH_SIZE) { - flush(); - } - } - } - - private ProfilerLogEntry formatLog(ILoggingEvent eventObject) { - String trace = getStackTraceFromEvent(eventObject); - long timestamp = eventObject.getTimeStamp(); - String message = eventObject.getFormattedMessage(); - String severity = eventObject.getLevel().toString(); - return new ProfilerLogEntry(timestamp, message, trace, severity); - } - - private void flush() { - sendLogs(); - } - - /** Send logs in a separate thread */ - private void sendLogs() { - synchronized (activeLogFlushes) { - activeLogFlushes.add(CompletableFuture.runAsync(() -> { - if (isFlusing.compareAndSet(false, true)) { - try { - if (teamscaleClient == null) { - // There might be no connection configured. - return; - } - - List logsToSend; - synchronized (logBuffer) { - logsToSend = new ArrayList<>(logBuffer); - } - - Call call = teamscaleClient.postProfilerLog(profilerId, logsToSend); - retrofit2.Response response = call.execute(); - if (!response.isSuccessful()) { - throw new IllegalStateException("Failed to send log: HTTP error code : " + response.code()); - } - - synchronized (logBuffer) { - // Removing the logs that have been sent after the fact. - // This handles problems with lost network connections. - logsToSend.forEach(logBuffer::remove); - } - } catch (Exception e) { - // We do not report on exceptions here. - if (!(e instanceof ConnectException)) { - addStatus(new ErrorStatus("Sending logs to Teamscale failed: " + e.getMessage(), this, e)); - } - } finally { - isFlusing.set(false); - } - } - }).whenComplete((result, throwable) -> { - synchronized (activeLogFlushes) { - activeLogFlushes.removeIf(CompletableFuture::isDone); - } - })); - } - } - - @Override - public void stop() { - // Already flush here once to make sure that we do not miss too much. - flush(); - - scheduler.shutdown(); - try { - if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { - scheduler.shutdownNow(); - } - } catch (InterruptedException e) { - scheduler.shutdownNow(); - } - - // A final flush after the scheduler has been shut down. - flush(); - - // Block until all flushes are done - CompletableFuture.allOf(activeLogFlushes.toArray(new CompletableFuture[0])).join(); - - super.stop(); - } - - public void setTeamscaleClient(ITeamscaleService teamscaleClient) { - this.teamscaleClient = teamscaleClient; - } - - public void setProfilerId(String profilerId) { - this.profilerId = profilerId; - } - - /** - * Add the {@link com.teamscale.jacoco.agent.logging.LogToTeamscaleAppender} to the logging configuration and - * enable/start it. - */ - public static boolean addTeamscaleAppenderTo(LoggerContext context, AgentOptions agentOptions) { - @Nullable TeamscaleClient client = agentOptions.createTeamscaleClient( - false); - if (client == null || agentOptions.configurationViaTeamscale == null) { - return false; - } - - ITeamscaleService serviceClient = client.getService(); - LogToTeamscaleAppender logToTeamscaleAppender = new LogToTeamscaleAppender(); - logToTeamscaleAppender.setContext(context); - logToTeamscaleAppender.setProfilerId(agentOptions.configurationViaTeamscale.getProfilerId()); - logToTeamscaleAppender.setTeamscaleClient(serviceClient); - logToTeamscaleAppender.start(); - - Logger rootLogger = context.getLogger(Logger.ROOT_LOGGER_NAME); - rootLogger.addAppender(logToTeamscaleAppender); - - return true; - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/logging/LoggingUtils.java b/agent/src/main/java/com/teamscale/jacoco/agent/logging/LoggingUtils.java deleted file mode 100644 index d836d8e6f..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/logging/LoggingUtils.java +++ /dev/null @@ -1,172 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2018 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent.logging; - -import ch.qos.logback.classic.LoggerContext; -import ch.qos.logback.classic.joran.JoranConfigurator; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.classic.spi.IThrowableProxy; -import ch.qos.logback.classic.spi.ThrowableProxy; -import ch.qos.logback.classic.spi.ThrowableProxyUtil; -import ch.qos.logback.core.joran.spi.JoranException; -import ch.qos.logback.core.util.StatusPrinter; -import com.teamscale.jacoco.agent.Agent; -import com.teamscale.jacoco.agent.util.NullOutputStream; -import com.teamscale.report.util.ILogger; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.io.FileInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.PrintStream; -import java.nio.file.Path; - -/** - * Helps initialize the logging framework properly. - */ -public class LoggingUtils { - - /** Returns a logger for the given object's class. */ - public static Logger getLogger(Object object) { - return LoggerFactory.getLogger(object.getClass()); - } - - /** Returns a logger for the given class. */ - public static Logger getLogger(Class object) { - return LoggerFactory.getLogger(object); - } - - /** Class to use with try-with-resources to close the logging framework's resources. */ - public static class LoggingResources implements AutoCloseable { - - @Override - public void close() { - getLoggerContext().stop(); - } - } - - /** Initializes the logging to the default configured in the Jar. */ - public static LoggingResources initializeDefaultLogging() { - InputStream stream = Agent.class.getResourceAsStream("logback-default.xml"); - reconfigureLoggerContext(stream); - return new LoggingResources(); - } - - /** - * Returns the logger context. - */ - public static LoggerContext getLoggerContext() { - return (LoggerContext) LoggerFactory.getILoggerFactory(); - } - - /** - * Extracts the stack trace from an ILoggingEvent using ThrowableProxyUtil. - * - * @param event the logging event containing the exception - * @return the stack trace as a String, or null if no exception is associated - */ - public static String getStackTraceFromEvent(ILoggingEvent event) { - IThrowableProxy throwableProxy = event.getThrowableProxy(); - - if (throwableProxy != null) { - // Use ThrowableProxyUtil to convert the IThrowableProxy to a String - return ThrowableProxyUtil.asString(throwableProxy); - } - - return null; - } - - /** - * Converts a Throwable to its stack trace as a String. - * - * @param throwable the throwable to convert - * @return the stack trace as a String - */ - public static String getStackTraceAsString(Throwable throwable) { - if (throwable == null) { - return null; - } - return ThrowableProxyUtil.asString(new ThrowableProxy(throwable)); - } - - /** - * Reconfigures the logger context to use the configuration XML from the given input stream. Cf. https://logback.qos.ch/manual/configuration.html - */ - private static void reconfigureLoggerContext(InputStream stream) { - StatusPrinter.setPrintStream(new PrintStream(new NullOutputStream())); - LoggerContext loggerContext = getLoggerContext(); - try { - JoranConfigurator configurator = new JoranConfigurator(); - configurator.setContext(loggerContext); - loggerContext.reset(); - configurator.doConfigure(stream); - } catch (JoranException je) { - // StatusPrinter will handle this - } - StatusPrinter.printInCaseOfErrorsOrWarnings(loggerContext); - } - - /** - * Initializes the logging from the given file. If that is null, uses {@link - * #initializeDefaultLogging()} instead. - */ - public static LoggingResources initializeLogging(Path loggingConfigFile) throws IOException { - if (loggingConfigFile == null) { - return initializeDefaultLogging(); - } - - reconfigureLoggerContext(new FileInputStream(loggingConfigFile.toFile())); - return new LoggingResources(); - } - - /** Initializes debug logging. */ - public static LoggingResources initializeDebugLogging(Path logDirectory) { - if (logDirectory != null) { - DebugLogDirectoryPropertyDefiner.filePath = logDirectory; - } - InputStream stream = Agent.class.getResourceAsStream("logback-default-debugging.xml"); - reconfigureLoggerContext(stream); - return new LoggingResources(); - } - - /** Wraps the given slf4j logger into an {@link ILogger}. */ - public static ILogger wrap(Logger logger) { - return new ILogger() { - @Override - public void debug(String message) { - logger.debug(message); - } - - @Override - public void info(String message) { - logger.info(message); - } - - @Override - public void warn(String message) { - logger.warn(message); - } - - @Override - public void warn(String message, Throwable throwable) { - logger.warn(message, throwable); - } - - @Override - public void error(Throwable throwable) { - logger.error(throwable.getMessage(), throwable); - } - - @Override - public void error(String message, Throwable throwable) { - logger.error(message, throwable); - } - }; - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/util/AgentUtils.java b/agent/src/main/java/com/teamscale/jacoco/agent/util/AgentUtils.java deleted file mode 100644 index e055ee8c1..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/util/AgentUtils.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.teamscale.jacoco.agent.util; - -import com.teamscale.client.FileSystemUtils; -import com.teamscale.client.TeamscaleServiceGenerator; -import com.teamscale.jacoco.agent.PreMain; -import com.teamscale.jacoco.agent.configuration.ProcessInformationRetriever; - -import java.io.IOException; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ResourceBundle; - -/** General utilities for working with the agent. */ -public class AgentUtils { - - /** Version of this program. */ - public static final String VERSION; - - /** User-Agent header value for HTTP requests. */ - public static final String USER_AGENT; - - private static Path mainTempDirectory = null; - - static { - ResourceBundle bundle = ResourceBundle.getBundle("com.teamscale.jacoco.agent.app"); - VERSION = bundle.getString("version"); - USER_AGENT = TeamscaleServiceGenerator.buildUserAgent("Teamscale Java Profiler", VERSION); - } - - /** - * Returns the main temporary directory where all agent temp files should be placed. - */ - public static Path getMainTempDirectory() { - if (mainTempDirectory == null) { - try { - // We add a trailing hyphen here to visually separate the PID from the random number that Java appends - // to the name to make it unique - mainTempDirectory = Files.createTempDirectory("teamscale-java-profiler-" + - FileSystemUtils.toSafeFilename(ProcessInformationRetriever.getPID()) + "-"); - } catch (IOException e) { - throw new RuntimeException("Failed to create temporary directory for agent files", e); - } - } - return mainTempDirectory; - } - - /** Returns the directory that contains the agent installation. */ - public static Path getAgentDirectory() { - try { - URI jarFileUri = PreMain.class.getProtectionDomain().getCodeSource().getLocation().toURI(); - // we assume that the dist zip is extracted and the agent jar not moved - Path jarDirectory = Paths.get(jarFileUri).getParent(); - Path installDirectory = jarDirectory.getParent(); - if (installDirectory == null) { - // happens when the jar file is stored in the root directory - return jarDirectory; - } - return installDirectory; - } catch (URISyntaxException e) { - throw new RuntimeException("Failed to obtain agent directory. This is a bug, please report it.", e); - } - } - -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/util/Assertions.java b/agent/src/main/java/com/teamscale/jacoco/agent/util/Assertions.java deleted file mode 100644 index b789c5f6a..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/util/Assertions.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.teamscale.jacoco.agent.util; - -import org.jetbrains.annotations.Contract; - -/** - * Simple methods to implement assertions. - */ -public class Assertions { - - /** - * Checks if a condition is true. - * - * @param condition condition to check - * @param message exception message - * @throws AssertionError if the condition is false - */ - @Contract(value = "false, _ -> fail", pure = true) - public static void isTrue(boolean condition, String message) throws AssertionError { - throwAssertionErrorIfTestFails(condition, message); - } - - /** - * Checks if a condition is false. - * - * @param condition condition to check - * @param message exception message - * @throws AssertionError if the condition is true - */ - @Contract(value = "true, _ -> fail", pure = true) - public static void isFalse(boolean condition, String message) throws AssertionError { - throwAssertionErrorIfTestFails(!condition, message); - } - - /** - * Throws an {@link AssertionError} if the test fails. - * - * @param test test which should be true - * @param message exception message - * @throws AssertionError if the test fails - */ - private static void throwAssertionErrorIfTestFails(boolean test, String message) { - if (!test) { - throw new AssertionError(message); - } - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/util/DaemonThreadFactory.java b/agent/src/main/java/com/teamscale/jacoco/agent/util/DaemonThreadFactory.java deleted file mode 100644 index 1ec3461ec..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/util/DaemonThreadFactory.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.teamscale.jacoco.agent.util; - -import java.util.concurrent.ThreadFactory; - -/** - * {@link ThreadFactory} that only produces deamon threads (threads that don't prevent JVM shutdown) with a fixed name. - */ -public class DaemonThreadFactory implements ThreadFactory { - - private final String threadName; - - public DaemonThreadFactory(Class owningClass, String threadName) { - this.threadName = "Teamscale Java Profiler " + owningClass.getSimpleName() + " " + threadName; - } - - @Override - public Thread newThread(Runnable runnable) { - Thread thread = new Thread(runnable, threadName); - thread.setDaemon(true); - return thread; - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/util/NullOutputStream.java b/agent/src/main/java/com/teamscale/jacoco/agent/util/NullOutputStream.java deleted file mode 100644 index 856e316a1..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/util/NullOutputStream.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.teamscale.jacoco.agent.util; - -import org.jetbrains.annotations.NotNull; - -import java.io.IOException; -import java.io.OutputStream; - -/** NOP output stream implementation. */ -public class NullOutputStream extends OutputStream { - - public NullOutputStream() { - // do nothing - } - - @Override - public void write(final byte @NotNull [] b, final int off, final int len) { - // to /dev/null - } - - @Override - public void write(final int b) { - // to /dev/null - } - - @Override - public void write(final byte @NotNull [] b) throws IOException { - // to /dev/null - } -} diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/util/Timer.java b/agent/src/main/java/com/teamscale/jacoco/agent/util/Timer.java deleted file mode 100644 index a6787e085..000000000 --- a/agent/src/main/java/com/teamscale/jacoco/agent/util/Timer.java +++ /dev/null @@ -1,59 +0,0 @@ -/*-------------------------------------------------------------------------+ -| | -| Copyright (c) 2009-2018 CQSE GmbH | -| | -+-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent.util; - -import java.time.Duration; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.ScheduledThreadPoolExecutor; -import java.util.concurrent.TimeUnit; - -/** - * Triggers a callback in a regular interval. Note that the spawned threads are - * Daemon threads, i.e. they will not prevent the JVM from shutting down. - *

- * The timer will abort if the given {@link #runnable} ever throws an exception. - */ -public class Timer { - - /** Runs the job on a background daemon thread. */ - private final ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1, runnable -> { - Thread thread = Executors.defaultThreadFactory().newThread(runnable); - thread.setDaemon(true); - return thread; - }); - - /** The currently running job or null. */ - private ScheduledFuture job = null; - - /** The job to execute periodically. */ - private final Runnable runnable; - - /** Duration between two job executions. */ - private final Duration duration; - - /** Constructor. */ - public Timer(Runnable runnable, Duration duration) { - this.runnable = runnable; - this.duration = duration; - } - - /** Starts the regular job. */ - public synchronized void start() { - if (job != null) { - return; - } - - job = executor.scheduleAtFixedRate(runnable, duration.toMinutes(), duration.toMinutes(), TimeUnit.MINUTES); - } - - /** Stops the regular job, possibly aborting it. */ - public synchronized void stop() { - job.cancel(false); - job = null; - } - -} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/Agent.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Agent.kt new file mode 100644 index 000000000..010e88409 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Agent.kt @@ -0,0 +1,170 @@ +package com.teamscale.jacoco.agent + +import com.teamscale.client.FileSystemUtils +import com.teamscale.client.StringUtils +import com.teamscale.jacoco.agent.logging.LoggingUtils +import com.teamscale.jacoco.agent.options.AgentOptions +import com.teamscale.jacoco.agent.upload.IUploadRetry +import com.teamscale.jacoco.agent.upload.IUploader +import com.teamscale.jacoco.agent.upload.teamscale.TeamscaleUploader +import com.teamscale.jacoco.agent.util.AgentUtils +import com.teamscale.report.jacoco.CoverageFile +import com.teamscale.report.jacoco.EmptyReportException +import com.teamscale.report.jacoco.JaCoCoXmlReportGenerator +import com.teamscale.report.jacoco.dump.Dump +import org.glassfish.jersey.server.ResourceConfig +import org.glassfish.jersey.server.ServerProperties +import java.io.File +import java.io.IOException +import java.lang.instrument.Instrumentation +import java.nio.file.Files +import java.util.Timer +import kotlin.concurrent.fixedRateTimer +import kotlin.io.path.deleteIfExists +import kotlin.io.path.listDirectoryEntries +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +/** + * A wrapper around the JaCoCo Java agent that automatically triggers a dump and XML conversion based on a time + * interval. + */ +class Agent(options: AgentOptions, instrumentation: Instrumentation?) : AgentBase(options) { + /** Converts binary data to XML. */ + private val generator: JaCoCoXmlReportGenerator + + /** Regular dump task. */ + private var timer: Timer? = null + + /** Stores the XML files. */ + private val uploader = options.createUploader(instrumentation) + + /** Constructor. */ + init { + logger.info("Upload method: {}", uploader.describe()) + retryUnsuccessfulUploads(options, uploader) + generator = JaCoCoXmlReportGenerator( + options.getClassDirectoriesOrZips(), + options.locationIncludeFilter, + options.getDuplicateClassFileBehavior(), + options.shouldIgnoreUncoveredClasses(), + LoggingUtils.wrap(logger) + ) + + if (options.shouldDumpInIntervals()) { + val period = options.dumpIntervalInMinutes.toDuration(DurationUnit.MINUTES).inWholeMilliseconds + timer = fixedRateTimer("Teamscale-Java-Profiler", true, period, period) { + dumpReport() + } + logger.info("Dumping every ${options.dumpIntervalInMinutes} minutes.") + } + options.teamscaleServerOptions.partition?.let { partition -> + controller.sessionId = partition + } + } + + /** + * If we have coverage that was leftover because of previously unsuccessful coverage uploads, we retry to upload + * them again with the same configuration as in the previous try. + */ + private fun retryUnsuccessfulUploads(options: AgentOptions, uploader: IUploader) { + var outputPath = options.outputDirectory + if (outputPath == null) { + // Default fallback + outputPath = AgentUtils.agentDirectory.resolve("coverage") + } + + val parentPath = outputPath.parent + if (parentPath == null) { + logger.error("The output path '{}' does not have a parent path. Canceling upload retry.", outputPath.toAbsolutePath()) + return + } + + parentPath.toFile().walk() + .filter { it.name.endsWith(TeamscaleUploader.RETRY_UPLOAD_FILE_SUFFIX) } + .forEach { file -> + reuploadCoverageFromPropertiesFile(file, uploader) + } + } + + private fun reuploadCoverageFromPropertiesFile(file: File, uploader: IUploader) { + logger.info("Retrying previously unsuccessful coverage upload for file {}.", file) + try { + val properties = FileSystemUtils.readProperties(file) + val coverageFile = CoverageFile( + File(StringUtils.stripSuffix(file.absolutePath, TeamscaleUploader.RETRY_UPLOAD_FILE_SUFFIX)) + ) + + if (uploader is IUploadRetry) { + uploader.reupload(coverageFile, properties) + } else { + logger.info("Reupload not implemented for uploader {}", uploader.describe()) + } + Files.deleteIfExists(file.toPath()) + } catch (e: IOException) { + logger.error("Reuploading coverage failed. $e") + } + } + + override fun initResourceConfig(): ResourceConfig? { + val resourceConfig = ResourceConfig() + resourceConfig.property(ServerProperties.WADL_FEATURE_DISABLE, true.toString()) + AgentResource.setAgent(this) + return resourceConfig.register(AgentResource::class.java).register(GenericExceptionMapper::class.java) + } + + override fun prepareShutdown() { + timer?.cancel() + if (options.shouldDumpOnExit()) dumpReport() + + val dir = options.outputDirectory + try { + if (dir.listDirectoryEntries().isEmpty()) dir.deleteIfExists() + } catch (e: IOException) { + logger.info( + ("Could not delete empty output directory {}. " + + "This directory was created inside the configured output directory to be able to " + + "distinguish between different runs of the profiled JVM. You may delete it manually."), + dir, e + ) + } + } + + /** + * Dumps the current execution data, converts it, writes it to the output directory defined in [.options] and + * uploads it if an uploader is configured. Logs any errors, never throws an exception. + */ + override fun dumpReport() { + logger.debug("Starting dump") + + try { + dumpReportUnsafe() + } catch (t: Throwable) { + // we want to catch anything in order to avoid crashing the whole system under + // test + logger.error("Dump job failed with an exception", t) + } + } + + private fun dumpReportUnsafe() { + val dump: Dump + try { + dump = controller.dumpAndReset() + } catch (e: JacocoRuntimeController.DumpException) { + logger.error("Dumping failed, retrying later", e) + return + } + + try { + benchmark("Generating the XML report") { + val outputFile = options.createNewFileInOutputDirectory("jacoco", "xml") + val coverageFile = generator.convertSingleDumpToReport(dump, outputFile) + uploader.upload(coverageFile) + } + } catch (e: IOException) { + logger.error("Converting binary dump to XML failed", e) + } catch (e: EmptyReportException) { + logger.error("No coverage was collected. ${e.message}", e) + } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentBase.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentBase.kt new file mode 100644 index 000000000..bfed53514 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentBase.kt @@ -0,0 +1,150 @@ +package com.teamscale.jacoco.agent + +import com.teamscale.jacoco.agent.logging.LoggingUtils +import com.teamscale.jacoco.agent.options.AgentOptions +import org.eclipse.jetty.server.Server +import org.eclipse.jetty.server.ServerConnector +import org.eclipse.jetty.servlet.ServletContextHandler +import org.eclipse.jetty.servlet.ServletHolder +import org.eclipse.jetty.util.thread.QueuedThreadPool +import org.glassfish.jersey.server.ResourceConfig +import org.glassfish.jersey.servlet.ServletContainer +import org.jacoco.agent.rt.RT +import org.slf4j.Logger +import java.lang.management.ManagementFactory + +/** + * Base class for agent implementations. Handles logger shutdown, store creation and instantiation of the + * [JacocoRuntimeController]. + * + * + * Subclasses must handle dumping onto disk and uploading via the configured uploader. + */ +abstract class AgentBase( + /** The agent options. */ + @JvmField var options: AgentOptions +) { + /** The logger. */ + val logger: Logger = LoggingUtils.getLogger(this) + + /** Controls the JaCoCo runtime. */ + @JvmField + val controller: JacocoRuntimeController + + private lateinit var server: Server + + /** + * Lazily generated string representation of the command line arguments to print to the log. + */ + private val optionsObjectToLog by lazy { + object { + override fun toString() = + if (options.shouldObfuscateSecurityRelatedOutputs()) { + options.getObfuscatedOptionsString() + } else { + options.getOriginalOptionsString() + } + } + } + + init { + try { + controller = JacocoRuntimeController(RT.getAgent()) + } catch (e: IllegalStateException) { + throw IllegalStateException("Teamscale Java Profiler not started or there is a conflict with another agent on the classpath.", e) + } + logger.info( + "Starting Teamscale Java Profiler for process {} with options: {}", + ManagementFactory.getRuntimeMXBean().name, optionsObjectToLog + ) + options.getHttpServerPort()?.let { port -> + try { + initServer() + } catch (e: Exception) { + logger.error("Could not start http server on port $port. Please check if the port is blocked.") + throw IllegalStateException("Control server not started.", e) + } + } + } + + /** + * Starts the http server, which waits for information about started and finished tests. + */ + @Throws(Exception::class) + private fun initServer() { + logger.info("Listening for test events on port {}.", options.getHttpServerPort()) + + // Jersey Implementation + val handler = buildUsingResourceConfig() + val threadPool = QueuedThreadPool() + threadPool.maxThreads = 10 + threadPool.isDaemon = true + + // Create a server instance and set the thread pool + server = Server(threadPool) + // Create a server connector, set the port and add it to the server + val connector = ServerConnector(server) + connector.port = options.getHttpServerPort() + server.addConnector(connector) + server.handler = handler + server.start() + } + + private fun buildUsingResourceConfig(): ServletContextHandler { + val handler = ServletContextHandler(ServletContextHandler.NO_SESSIONS) + handler.contextPath = "/" + + val resourceConfig = initResourceConfig() + handler.addServlet(ServletHolder(ServletContainer(resourceConfig)), "/*") + return handler + } + + /** + * Initializes the [ResourceConfig] needed for the Jetty + Jersey Server + */ + protected abstract fun initResourceConfig(): ResourceConfig? + + /** + * Registers a shutdown hook that stops the timer and dumps coverage a final time. + */ + fun registerShutdownHook() { + Runtime.getRuntime().addShutdownHook(Thread { + try { + logger.info("Teamscale Java Profiler is shutting down...") + stopServer() + prepareShutdown() + logger.info("Teamscale Java Profiler successfully shut down.") + } catch (e: Exception) { + logger.error("Exception during profiler shutdown.", e) + } finally { + // Try to flush logging resources also in case of an exception during shutdown + PreMain.closeLoggingResources() + } + }) + } + + /** Stop the http server if it's running */ + fun stopServer() { + options.getHttpServerPort()?.let { + try { + server.stop() + } catch (e: Exception) { + logger.error("Could not stop server so it is killed now.", e) + } finally { + server.destroy() + } + } + } + + /** Called when the shutdown hook is triggered. */ + protected open fun prepareShutdown() { + // Template method to be overridden by subclasses. + } + + /** + * Dumps the current execution data, converts it, writes it to the output + * directory defined in [.options] and uploads it if an uploader is + * configured. Logs any errors, never throws an exception. + */ + abstract fun dumpReport() +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentResource.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentResource.kt new file mode 100644 index 000000000..94447f400 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/AgentResource.kt @@ -0,0 +1,41 @@ +package com.teamscale.jacoco.agent + +import javax.ws.rs.POST +import javax.ws.rs.Path +import javax.ws.rs.core.Response + +/** + * The resource of the Jersey + Jetty http server holding all the endpoints specific for the [Agent]. + */ +@Path("/") +class AgentResource : ResourceBase() { + /** Handles dumping a XML coverage report for coverage collected until now. */ + @POST + @Path("/dump") + fun handleDump(): Response? { + logger.debug("Dumping report triggered via HTTP request") + agent.dumpReport() + return Response.noContent().build() + } + + /** Handles resetting of coverage. */ + @POST + @Path("/reset") + fun handleReset(): Response? { + logger.debug("Resetting coverage triggered via HTTP request") + agent.controller.reset() + return Response.noContent().build() + } + + companion object { + private lateinit var agent: Agent + + /** + * Static setter to inject the [Agent] to the resource. + */ + fun setAgent(agent: Agent) { + Companion.agent = agent + agentBase = agent + } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/DelayedLogger.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/DelayedLogger.kt new file mode 100644 index 000000000..d242ff79d --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/DelayedLogger.kt @@ -0,0 +1,52 @@ +package com.teamscale.jacoco.agent + +import com.teamscale.report.util.ILogger +import org.slf4j.Logger +import java.util.function.Consumer + +/** + * A logger that buffers logs in memory and writes them to the actual logger at a later point. This is needed when stuff + * needs to be logged before the actual logging framework is initialized. + */ +class DelayedLogger : ILogger { + /** List of log actions that will be executed once the logger is initialized. */ + private val logActions = mutableListOf Unit>() + + override fun debug(message: String) { + logActions.add { debug(message) } + } + + override fun info(message: String) { + logActions.add { info(message) } + } + + override fun warn(message: String) { + logActions.add { warn(message) } + } + + override fun warn(message: String, throwable: Throwable?) { + logActions.add { warn(message, throwable) } + } + + override fun error(throwable: Throwable) { + logActions.add { error(throwable.message, throwable) } + } + + override fun error(message: String, throwable: Throwable?) { + logActions.add { error(message, throwable) } + } + + /** + * Logs an error and also writes the message to [System.err] to ensure the message is even logged in case + * setting up the logger itself fails for some reason (see TS-23151). + */ + fun errorAndStdErr(message: String?, throwable: Throwable?) { + System.err.println(message) + logActions.add { error(message, throwable) } + } + + /** Writes the logs to the given slf4j logger. */ + fun logTo(logger: Logger) { + logActions.forEach { action -> action(logger) } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/GenericExceptionMapper.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/GenericExceptionMapper.kt new file mode 100644 index 000000000..c7f0f9909 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/GenericExceptionMapper.kt @@ -0,0 +1,18 @@ +package com.teamscale.jacoco.agent + +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response +import javax.ws.rs.ext.ExceptionMapper +import javax.ws.rs.ext.Provider + +/** + * Generates a [javax.ws.rs.core.Response] for an exception. + */ +@Provider +class GenericExceptionMapper : ExceptionMapper { + override fun toResponse(e: Throwable?): Response = + Response.status(Response.Status.INTERNAL_SERVER_ERROR).apply { + type(MediaType.TEXT_PLAIN_TYPE) + entity("Message: ${e?.message}") + }.build() +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/Helpers.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Helpers.kt new file mode 100644 index 000000000..d3dc43a97 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Helpers.kt @@ -0,0 +1,6 @@ +package com.teamscale.jacoco.agent + +import kotlin.time.measureTime + +fun benchmark(name: String, action: () -> Unit) = + measureTime { action() }.also { duration -> Main.logger.debug("$name took $duration") } \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/JacocoRuntimeController.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/JacocoRuntimeController.kt new file mode 100644 index 000000000..0a76c0d3e --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/JacocoRuntimeController.kt @@ -0,0 +1,118 @@ +package com.teamscale.jacoco.agent + +import com.teamscale.report.jacoco.dump.Dump +import org.jacoco.agent.rt.IAgent +import org.jacoco.core.data.ExecutionData +import org.jacoco.core.data.ExecutionDataReader +import org.jacoco.core.data.ExecutionDataStore +import org.jacoco.core.data.IExecutionDataVisitor +import org.jacoco.core.data.ISessionInfoVisitor +import org.jacoco.core.data.SessionInfo +import java.io.ByteArrayInputStream +import java.io.File +import java.io.FileOutputStream +import java.io.IOException + +/** + * Wrapper around JaCoCo's [RT] runtime interface. + * + * + * Can be used if the calling code is run in the same JVM as the agent is attached to. + */ +class JacocoRuntimeController +/** Constructor. */( + /** JaCoCo's [RT] agent instance */ + private val agent: IAgent +) { + /** Indicates a failed dump. */ + class DumpException(message: String?, cause: Throwable?) : Exception(message, cause) + + /** + * Dumps execution data and resets it. + * + * @throws DumpException if dumping fails. This should never happen in real life. Dumping should simply be retried + * later if this ever happens. + */ + @Throws(DumpException::class) + fun dumpAndReset(): Dump { + val binaryData = agent.getExecutionData(true) + + try { + ByteArrayInputStream(binaryData).use { inputStream -> + ExecutionDataReader(inputStream).apply { + val store = ExecutionDataStore() + setExecutionDataVisitor { store.put(it) } + val sessionInfoVisitor = SessionInfoVisitor() + setSessionInfoVisitor(sessionInfoVisitor) + read() + return Dump(sessionInfoVisitor.sessionInfo, store) + } + } + } catch (e: IOException) { + throw DumpException("should never happen for the ByteArrayInputStream", e) + } + } + + /** + * Dumps execution data to the given file and resets it afterwards. + */ + @Throws(IOException::class) + fun dumpToFileAndReset(file: File) { + val binaryData = agent.getExecutionData(true) + + FileOutputStream(file, true).use { outputStream -> + outputStream.write(binaryData) + } + } + + + /** + * Dumps execution data to a file and resets it. + * + * @throws DumpException if dumping fails. This should never happen in real life. Dumping should simply be retried + * later if this ever happens. + */ + @Throws(DumpException::class) + fun dump() { + try { + agent.dump(true) + } catch (e: IOException) { + throw DumpException(e.message, e) + } + } + + /** Resets already collected coverage. */ + fun reset() { + agent.reset() + } + + var sessionId: String? + /** Returns the current sessionId. */ + get() = agent.sessionId + /** + * Sets the current sessionId of the agent that can be used to identify which coverage is recorded from now on. + */ + set(sessionId) { + agent.setSessionId(sessionId) + } + + /** Unsets the session ID so that coverage collected from now on is not attributed to the previous test. */ + fun resetSessionId() { + agent.sessionId = "" + } + + /** + * Receives and stores a [org.jacoco.core.data.SessionInfo]. Has a fallback dummy session in case nothing is received. + */ + private class SessionInfoVisitor : ISessionInfoVisitor { + /** The received session info or a dummy. */ + var sessionInfo: SessionInfo = SessionInfo( + "dummysession", System.currentTimeMillis(), System.currentTimeMillis() + ) + + /** {@inheritDoc} */ + override fun visitSessionInfo(info: SessionInfo) { + this.sessionInfo = info + } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt new file mode 100644 index 000000000..dd9094e71 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/LenientCoverageTransformer.kt @@ -0,0 +1,51 @@ +package com.teamscale.jacoco.agent + +import org.jacoco.agent.rt.internal_29a6edd.CoverageTransformer +import org.jacoco.agent.rt.internal_29a6edd.IExceptionLogger +import org.jacoco.agent.rt.internal_29a6edd.core.runtime.AgentOptions +import org.jacoco.agent.rt.internal_29a6edd.core.runtime.IRuntime +import org.slf4j.Logger +import java.lang.instrument.IllegalClassFormatException +import java.security.ProtectionDomain + +/** + * A class file transformer which delegates to the JaCoCo [org.jacoco.agent.rt.internal_29a6edd.CoverageTransformer] to do the actual instrumentation, + * but treats instrumentation errors e.g. due to unsupported class file versions more lenient by only logging them, but + * not bailing out completely. Those unsupported classes will not be instrumented and will therefore not be contained in + * the collected coverage report. + */ +class LenientCoverageTransformer( + runtime: IRuntime?, + options: AgentOptions, + private val logger: Logger +) : CoverageTransformer( + runtime, + options, + // The coverage transformer only uses the logger to print an error when the instrumentation fails. + // We want to show our more specific error message instead, so we only log this for debugging at trace. + IExceptionLogger { logger.trace(it.message, it) } +) { + override fun transform( + loader: ClassLoader?, + classname: String, + classBeingRedefined: Class<*>?, + protectionDomain: ProtectionDomain?, + classfileBuffer: ByteArray + ): ByteArray? { + try { + return super.transform(loader, classname, classBeingRedefined, protectionDomain, classfileBuffer) + } catch (e: IllegalClassFormatException) { + logger.error( + "Failed to instrument $classname. File will be skipped from instrumentation. " + + "No coverage will be collected for it. Exclude the file from the instrumentation or try " + + "updating the Teamscale Java Profiler if the file should actually be instrumented. (Cause: ${getRootCauseMessage(e)})" + ) + return null + } + } + + companion object { + private fun getRootCauseMessage(e: Throwable): String? = + e.cause?.let { getRootCauseMessage(it) } ?: e.message + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/Main.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Main.kt new file mode 100644 index 000000000..e1efc8a12 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/Main.kt @@ -0,0 +1,81 @@ +package com.teamscale.jacoco.agent + +import com.beust.jcommander.JCommander +import com.beust.jcommander.Parameter +import com.beust.jcommander.ParameterException +import com.teamscale.client.StringUtils +import com.teamscale.jacoco.agent.convert.ConvertCommand +import com.teamscale.jacoco.agent.logging.LoggingUtils +import com.teamscale.jacoco.agent.util.AgentUtils +import org.jacoco.core.JaCoCo +import org.slf4j.Logger +import kotlin.system.exitProcess + +/** Provides a command line interface for interacting with JaCoCo. */ +object Main { + /** The logger. */ + val logger: Logger = LoggingUtils.getLogger(this) + + /** The default arguments that will always be parsed. */ + private val defaultArguments = DefaultArguments() + + /** The arguments for the one-time conversion process. */ + private val command = ConvertCommand() + + /** Entry point. */ + @Throws(Exception::class) + @JvmStatic + fun main(args: Array) { + parseCommandLineAndRun(args) + } + + /** + * Parses the given command line arguments. Exits the program or throws an exception if the arguments are not valid. + * Then runs the specified command. + */ + @Throws(Exception::class) + private fun parseCommandLineAndRun(args: Array) { + val builder = createJCommanderBuilder() + val jCommander = builder.build() + + try { + jCommander.parse(*args) + } catch (e: ParameterException) { + handleInvalidCommandLine(jCommander, e.message) + } + + if (defaultArguments.help) { + println("Teamscale Java Profiler ${AgentUtils.VERSION} compiled against JaCoCo ${JaCoCo.VERSION}") + jCommander.usage() + return + } + + val validator = command.validate() + if (!validator.isValid) { + handleInvalidCommandLine(jCommander, StringUtils.LINE_FEED + validator.errorMessage) + } + + logger.info("Starting Teamscale Java Profiler ${AgentUtils.VERSION} compiled against JaCoCo ${JaCoCo.VERSION}") + command.run() + } + + /** Shows an informative error and help message. Then exits the program. */ + private fun handleInvalidCommandLine(jCommander: JCommander, message: String?) { + System.err.println("Invalid command line: $message${StringUtils.LINE_FEED}") + jCommander.usage() + exitProcess(1) + } + + /** Creates a builder for a [com.beust.jcommander.JCommander] object. */ + private fun createJCommanderBuilder() = + JCommander.newBuilder().programName(Main::class.java.getName()) + .addObject(defaultArguments) + .addObject(command) + + /** Default arguments that may always be provided. */ + private class DefaultArguments { + /** Shows the help message. */ + @Parameter(names = ["--help"], help = true, description = "Shows all available command line arguments.") + val help = false + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/PreMain.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/PreMain.kt new file mode 100644 index 000000000..403b756fa --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/PreMain.kt @@ -0,0 +1,307 @@ +package com.teamscale.jacoco.agent + +import com.teamscale.client.FileSystemUtils +import com.teamscale.client.HttpUtils +import com.teamscale.client.StringUtils +import com.teamscale.jacoco.agent.configuration.AgentOptionReceiveException +import com.teamscale.jacoco.agent.logging.DebugLogDirectoryPropertyDefiner +import com.teamscale.jacoco.agent.logging.LogDirectoryPropertyDefiner +import com.teamscale.jacoco.agent.logging.LogToTeamscaleAppender +import com.teamscale.jacoco.agent.logging.LoggingUtils +import com.teamscale.jacoco.agent.options.AgentOptionParseException +import com.teamscale.jacoco.agent.options.AgentOptions +import com.teamscale.jacoco.agent.options.AgentOptionsParser +import com.teamscale.jacoco.agent.options.FilePatternResolver +import com.teamscale.jacoco.agent.options.JacocoAgentOptionsBuilder +import com.teamscale.jacoco.agent.options.TeamscalePropertiesUtils +import com.teamscale.jacoco.agent.testimpact.TestwiseCoverageAgent +import com.teamscale.jacoco.agent.upload.UploaderException +import com.teamscale.jacoco.agent.util.AgentUtils +import com.teamscale.report.util.ILogger +import java.io.IOException +import java.lang.instrument.Instrumentation +import java.lang.management.ManagementFactory +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.Optional +import kotlin.use + +/** Container class for the premain entry point for the agent. */ +object PreMain { + private lateinit var loggingResources: LoggingUtils.LoggingResources + + /** + * System property that we use to prevent this agent from being attached to the same VM twice. This can happen if + * the agent is registered via multiple JVM environment variables and/or the command line at the same time. + */ + private const val LOCKING_SYSTEM_PROPERTY = "TEAMSCALE_JAVA_PROFILER_ATTACHED" + + /** + * Environment variable from which to read the config ID to use. This is an ID for a profiler configuration that is + * stored in Teamscale. + */ + private const val CONFIG_ID_ENVIRONMENT_VARIABLE = "TEAMSCALE_JAVA_PROFILER_CONFIG_ID" + + /** Environment variable from which to read the config file to use. */ + private const val CONFIG_FILE_ENVIRONMENT_VARIABLE = "TEAMSCALE_JAVA_PROFILER_CONFIG_FILE" + + /** Environment variable from which to read the Teamscale access token. */ + private const val ACCESS_TOKEN_ENVIRONMENT_VARIABLE = "TEAMSCALE_ACCESS_TOKEN" + + /** + * Entry point for the agent, called by the JVM. + */ + @JvmStatic + @Throws(Exception::class) + fun premain(options: String?, instrumentation: Instrumentation?) { + if (System.getProperty(LOCKING_SYSTEM_PROPERTY) != null) return + System.setProperty(LOCKING_SYSTEM_PROPERTY, "true") + + val environmentConfigId = System.getenv(CONFIG_ID_ENVIRONMENT_VARIABLE) + val environmentConfigFile = System.getenv(CONFIG_FILE_ENVIRONMENT_VARIABLE) + if (StringUtils.isEmpty(options) && environmentConfigId == null && environmentConfigFile == null) { + // profiler was registered globally, and no config was set explicitly by the user, thus ignore this process + // and don't profile anything + return + } + + var agentOptions: AgentOptions? = null + try { + val parseResult = getAndApplyAgentOptions( + options, environmentConfigId, environmentConfigFile + ) + agentOptions = parseResult.first + + // After parsing everything and configuring logging, we now + // can throw the caught exceptions. + parseResult.second?.forEach { exception -> + throw exception + } + } catch (e: AgentOptionParseException) { + LoggingUtils.loggerContext.getLogger(PreMain::class.java).error(e.message, e) + + // Flush logs to Teamscale, if configured. + closeLoggingResources() + + // Unregister the profiler from Teamscale. + agentOptions?.configurationViaTeamscale?.unregisterProfiler() + + throw e + } catch (_: AgentOptionReceiveException) { + // When Teamscale is not available, we don't want to fail hard to still allow for testing even if no + // coverage is collected (see TS-33237) + return + } + + val logger = LoggingUtils.getLogger(Agent::class.java) + + logger.info("Teamscale Java profiler version ${AgentUtils.VERSION}") + logger.info("Starting JaCoCo's agent") + val agentBuilder = JacocoAgentOptionsBuilder(agentOptions) + JaCoCoPreMain.premain(agentBuilder.createJacocoAgentOptions(), instrumentation, logger) + + agentOptions.configurationViaTeamscale?.startHeartbeatThreadAndRegisterShutdownHook() + createAgent(agentOptions, instrumentation).registerShutdownHook() + } + + @Throws(AgentOptionParseException::class, IOException::class, AgentOptionReceiveException::class) + private fun getAndApplyAgentOptions( + options: String?, + environmentConfigId: String?, + environmentConfigFile: String? + ): Pair?> { + val delayedLogger = DelayedLogger() + val javaAgents = ManagementFactory.getRuntimeMXBean().inputArguments + .filter { it.contains("-javaagent") } + // We allow multiple instances of the teamscale-jacoco-agent as we ensure with the #LOCKING_SYSTEM_PROPERTY to only use it once + val differentAgents = javaAgents.filter { !it.contains("teamscale-jacoco-agent.jar") } + + if (!differentAgents.isEmpty()) { + delayedLogger.warn( + "Using multiple java agents could interfere with coverage recording: ${ + differentAgents.joinToString() + }" + ) + } + if (!javaAgents.first().contains("teamscale-jacoco-agent.jar")) { + delayedLogger.warn("For best results consider registering the Teamscale Java Profiler first.") + } + + val credentials = TeamscalePropertiesUtils.parseCredentials() + if (credentials == null) { + // As many users still don't use the installer based setup, this log message will be shown in almost every log. + // We use a debug log, as this message can be confusing for customers that think a teamscale.properties file is synonymous with a config file. + delayedLogger.debug( + "No explicit teamscale.properties file given. Looking for Teamscale credentials in a config file or via a command line argument. This is expected unless the installer based setup was used." + ) + } + + val environmentAccessToken = System.getenv(ACCESS_TOKEN_ENVIRONMENT_VARIABLE) + + val parseResult: Pair> + val agentOptions: AgentOptions + try { + parseResult = AgentOptionsParser.parse( + options, environmentConfigId, environmentConfigFile, credentials, environmentAccessToken, delayedLogger + ) + agentOptions = parseResult.first + } catch (e: AgentOptionParseException) { + initializeFallbackLogging(options, delayedLogger).use { _ -> + delayedLogger.errorAndStdErr("Failed to parse agent options: ${e.message}", e) + attemptLogAndThrow(delayedLogger) + throw e + } + } catch (e: AgentOptionReceiveException) { + initializeFallbackLogging(options, delayedLogger).use { _ -> + delayedLogger.errorAndStdErr("${e.message} The application should start up normally, but NO coverage will be collected! Check the log file for details.", e) + attemptLogAndThrow(delayedLogger) + throw e + } + } + + initializeLogging(agentOptions, delayedLogger) + val logger = LoggingUtils.getLogger(Agent::class.java) + delayedLogger.logTo(logger) + HttpUtils.setShouldValidateSsl(agentOptions.shouldValidateSsl()) + + return parseResult + } + + private fun attemptLogAndThrow(delayedLogger: DelayedLogger) { + // We perform actual logging output after writing to console to + // ensure the console is reached even in case of logging issues + // (see TS-23151). We use the Agent class here (same as below) + val logger = LoggingUtils.getLogger(Agent::class.java) + delayedLogger.logTo(logger) + } + + /** Initializes logging during [premain] and also logs the log directory. */ + @Throws(IOException::class) + private fun initializeLogging(agentOptions: AgentOptions, logger: DelayedLogger) { + if (agentOptions.isDebugLogging) { + initializeDebugLogging(agentOptions, logger) + } else { + loggingResources = LoggingUtils.initializeLogging(agentOptions.getLoggingConfig()) + logger.info("Logging to ${LogDirectoryPropertyDefiner().getPropertyValue()}") + } + + if (agentOptions.teamscaleServerOptions.isConfiguredForServerConnection) { + if (LogToTeamscaleAppender.addTeamscaleAppenderTo(LoggingUtils.loggerContext, agentOptions)) { + logger.info("Logs are being forwarded to Teamscale at ${agentOptions.teamscaleServerOptions.url}") + } + } + } + + /** Closes the opened logging contexts. */ + fun closeLoggingResources() { + loggingResources.close() + } + + /** + * Returns in instance of the agent that was configured. Either an agent with interval based line-coverage dump or + * the HTTP server is used. + */ + @Throws(UploaderException::class, IOException::class) + private fun createAgent( + agentOptions: AgentOptions, + instrumentation: Instrumentation? + ): AgentBase = if (agentOptions.useTestwiseCoverageMode()) { + TestwiseCoverageAgent.create(agentOptions) + } else { + Agent(agentOptions, instrumentation) + } + + /** + * Initializes debug logging during [.premain] and also logs the log directory if + * given. + */ + private fun initializeDebugLogging(agentOptions: AgentOptions, logger: DelayedLogger) { + loggingResources = LoggingUtils.initializeDebugLogging(agentOptions.getDebugLogDirectory()) + val logDirectory = Paths.get(DebugLogDirectoryPropertyDefiner().getPropertyValue()) + if (FileSystemUtils.isValidPath(logDirectory.toString()) && Files.isWritable(logDirectory)) { + logger.info("Logging to $logDirectory") + } else { + logger.warn("Could not create $logDirectory. Logging to console only.") + } + } + + /** + * Initializes fallback logging in case of an error during the parsing of the options to + * [premain] (see TS-23151). This tries to extract the logging configuration and use + * this and falls back to the default logger. + */ + private fun initializeFallbackLogging( + premainOptions: String?, + delayedLogger: DelayedLogger + ): LoggingUtils.LoggingResources? { + if (premainOptions == null) { + return LoggingUtils.initializeDefaultLogging() + } + premainOptions + .split(",".toRegex()) + .dropLastWhile { it.isEmpty() } + .forEach { optionPart -> + if (optionPart.startsWith(AgentOptionsParser.DEBUG + "=")) { + val value = optionPart.split("=".toRegex(), limit = 2)[1] + val debugDisabled = value.equals("false", ignoreCase = true) + val debugEnabled = value.equals("true", ignoreCase = true) + if (debugDisabled) return@forEach + var debugLogDirectory: Path? = null + if (!value.isEmpty() && !debugEnabled) { + debugLogDirectory = Paths.get(value) + } + return LoggingUtils.initializeDebugLogging(debugLogDirectory) + } + if (optionPart.startsWith(AgentOptionsParser.LOGGING_CONFIG_OPTION + "=")) { + return createFallbackLoggerFromConfig( + optionPart.split("=".toRegex(), limit = 2)[1], + delayedLogger + ) + } + + if (optionPart.startsWith(AgentOptionsParser.CONFIG_FILE_OPTION + "=")) { + val configFileValue = optionPart.split("=".toRegex(), limit = 2)[1] + var loggingConfigLine: String? = null + try { + val configFile = FilePatternResolver(delayedLogger).parsePath( + AgentOptionsParser.CONFIG_FILE_OPTION, configFileValue + ).toFile() + loggingConfigLine = FileSystemUtils.readLinesUTF8(configFile) + .firstOrNull { it.startsWith(AgentOptionsParser.LOGGING_CONFIG_OPTION + "=") } + } catch (e: IOException) { + delayedLogger.error("Failed to load configuration from $configFileValue: ${e.message}", e) + } + loggingConfigLine?.let { config -> + return createFallbackLoggerFromConfig( + config.split("=".toRegex(), limit = 2)[1], delayedLogger + ) + } + } + } + + return LoggingUtils.initializeDefaultLogging() + } + + /** Creates a fallback logger using the given config file. */ + private fun createFallbackLoggerFromConfig( + configLocation: String, + delayedLogger: ILogger + ): LoggingUtils.LoggingResources { + try { + return LoggingUtils.initializeLogging( + FilePatternResolver(delayedLogger).parsePath( + AgentOptionsParser.LOGGING_CONFIG_OPTION, + configLocation + ) + ) + } catch (e: IOException) { + val message = "Failed to load log configuration from location $configLocation: ${e.message}" + delayedLogger.error(message, e) + // output the message to console as well, as this might + // otherwise not make it to the user + System.err.println(message) + return LoggingUtils.initializeDefaultLogging() + } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/ResourceBase.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/ResourceBase.kt new file mode 100644 index 000000000..fd4bb77cc --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/ResourceBase.kt @@ -0,0 +1,141 @@ +package com.teamscale.jacoco.agent + +import com.teamscale.client.CommitDescriptor +import com.teamscale.client.StringUtils +import com.teamscale.client.TeamscaleServer +import com.teamscale.jacoco.agent.logging.LoggingUtils +import com.teamscale.report.testwise.model.RevisionInfo +import org.jetbrains.annotations.Contract +import org.slf4j.Logger +import java.util.Optional +import javax.ws.rs.BadRequestException +import javax.ws.rs.GET +import javax.ws.rs.PUT +import javax.ws.rs.Path +import javax.ws.rs.Produces +import javax.ws.rs.core.MediaType +import javax.ws.rs.core.Response + +/** + * The resource of the Jersey + Jetty http server holding all the endpoints specific for the [AgentBase]. + */ +abstract class ResourceBase { + /** The logger. */ + @JvmField + protected val logger: Logger = LoggingUtils.getLogger(this) + + companion object { + /** + * The agentBase inject via [AgentResource.setAgent] or + * [com.teamscale.jacoco.agent.testimpact.TestwiseCoverageResource.setAgent]. + */ + @JvmStatic + protected lateinit var agentBase: AgentBase + } + + @get:Path("/partition") + @get:GET + val partition: String + /** Returns the partition for the Teamscale upload. */ + get() = agentBase.options.teamscaleServerOptions.partition.orEmpty() + + @get:Path("/message") + @get:GET + val message: String + /** Returns the upload message for the Teamscale upload. */ + get() = agentBase.options.teamscaleServerOptions.message.orEmpty() + + @get:Produces(MediaType.APPLICATION_JSON) + @get:Path("/revision") + @get:GET + val revision: RevisionInfo + /** Returns revision information for the Teamscale upload. */ + get() = revisionInfo + + @get:Produces(MediaType.APPLICATION_JSON) + @get:Path("/commit") + @get:GET + val commit: RevisionInfo + /** Returns revision information for the Teamscale upload. */ + get() = revisionInfo + + /** Handles setting the partition name. */ + @PUT + @Path("/partition") + fun setPartition(partitionString: String): Response { + val partition = StringUtils.removeDoubleQuotes(partitionString) + if (partition.isEmpty()) { + handleBadRequest("The new partition name is missing in the request body! Please add it as plain text.") + } + + logger.debug("Changing partition name to $partition") + agentBase.dumpReport() + agentBase.controller.sessionId = partition + agentBase.options.teamscaleServerOptions.partition = partition + return Response.noContent().build() + } + + /** Handles setting the upload message. */ + @PUT + @Path("/message") + fun setMessage(messageString: String): Response { + val message = StringUtils.removeDoubleQuotes(messageString) + if (message.isEmpty()) { + handleBadRequest("The new message is missing in the request body! Please add it as plain text.") + } + + agentBase.dumpReport() + logger.debug("Changing message to $message") + agentBase.options.teamscaleServerOptions.message = message + + return Response.noContent().build() + } + + /** Handles setting the revision. */ + @PUT + @Path("/revision") + fun setRevision(revisionString: String): Response { + val revision = StringUtils.removeDoubleQuotes(revisionString) + if (revision.isEmpty()) { + handleBadRequest("The new revision name is missing in the request body! Please add it as plain text.") + } + + agentBase.dumpReport() + logger.debug("Changing revision name to $revision") + agentBase.options.teamscaleServerOptions.revision = revision + + return Response.noContent().build() + } + + /** Handles setting the upload commit. */ + @PUT + @Path("/commit") + fun setCommit(commitString: String): Response { + val commit = StringUtils.removeDoubleQuotes(commitString) + if (commit.isEmpty()) { + handleBadRequest("The new upload commit is missing in the request body! Please add it as plain text.") + } + + agentBase.dumpReport() + agentBase.options.teamscaleServerOptions.commit = CommitDescriptor.parse(commit) + + return Response.noContent().build() + } + + private val revisionInfo: RevisionInfo + /** Returns revision information for the Teamscale upload. */ + get() { + val server = agentBase.options.teamscaleServerOptions + return RevisionInfo(server.commit, server.revision) + } + + /** + * Handles bad requests to the endpoints. + */ + @Contract(value = "_ -> fail") + @Throws(BadRequestException::class) + protected fun handleBadRequest(message: String?) { + logger.error(message) + throw BadRequestException(message) + } +} \ No newline at end of file diff --git a/agent/src/main/java/com/teamscale/jacoco/agent/commandline/ICommand.java b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/ICommand.kt similarity index 77% rename from agent/src/main/java/com/teamscale/jacoco/agent/commandline/ICommand.java rename to agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/ICommand.kt index 4ce2fa697..5bbc9b7cd 100644 --- a/agent/src/main/java/com/teamscale/jacoco/agent/commandline/ICommand.java +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/ICommand.kt @@ -3,27 +3,26 @@ | Copyright (c) 2009-2017 CQSE GmbH | | | +-------------------------------------------------------------------------*/ -package com.teamscale.jacoco.agent.commandline; +package com.teamscale.jacoco.agent.commandline -import com.teamscale.jacoco.agent.options.AgentOptionParseException; - -import java.io.IOException; +import com.teamscale.jacoco.agent.options.AgentOptionParseException +import java.io.IOException /** * Interface for commands: argument parsing and execution. */ -public interface ICommand { - +interface ICommand { /** * Makes sure the arguments are valid. Must return all detected problems in the * form of a user-visible message. */ - Validator validate() throws AgentOptionParseException, IOException; + @Throws(AgentOptionParseException::class, IOException::class) + fun validate(): Validator /** * Runs the implementation of the command. May throw an exception to indicate * abnormal termination of the program. */ - void run() throws Exception; - + @Throws(Exception::class) + fun run() } \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/Validator.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/Validator.kt new file mode 100644 index 000000000..6f520dc7d --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/commandline/Validator.kt @@ -0,0 +1,61 @@ +/*-------------------------------------------------------------------------+ +| | +| Copyright (c) 2009-2017 CQSE GmbH | +| | ++-------------------------------------------------------------------------*/ +package com.teamscale.jacoco.agent.commandline + +import com.teamscale.client.StringUtils +import com.teamscale.jacoco.agent.util.Assertions + +/** + * Helper class to allow for multiple validations to occur. + */ +class Validator { + /** The found validation problems in the form of error messages for the user. */ + private val messages = mutableListOf() + + /** Runs the given validation routine. */ + fun ensure(validation: ExceptionBasedValidation) { + try { + validation.validate() + } catch (e: Exception) { + e.message?.let { messages.add(it) } + } catch (e: AssertionError) { + e.message?.let { messages.add(it) } + } + } + + /** + * Interface for a validation routine that throws an exception when it fails. + */ + fun interface ExceptionBasedValidation { + /** + * Throws an [Exception] or [AssertionError] if the validation fails. + */ + @Throws(Exception::class, AssertionError::class) + fun validate() + } + + /** + * Checks that the given condition is `true` or adds the given error message. + */ + fun isTrue(condition: Boolean, message: String?) { + ensure { Assertions.isTrue(condition, message) } + } + + /** + * Checks that the given condition is `false` or adds the given error message. + */ + fun isFalse(condition: Boolean, message: String?) { + ensure { Assertions.isFalse(condition, message) } + } + + val isValid: Boolean + /** Returns `true` if the validation succeeded. */ + get() = messages.isEmpty() + + val errorMessage: String + /** Returns an error message with all validation problems that were found. */ + get() = "- ${messages.joinToString("${StringUtils.LINE_FEED}- ")}" +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/AgentOptionReceiveException.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/AgentOptionReceiveException.kt new file mode 100644 index 000000000..40c0a3104 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/AgentOptionReceiveException.kt @@ -0,0 +1,7 @@ +package com.teamscale.jacoco.agent.configuration + +/** Thrown when retrieving the profiler configuration from Teamscale fails. */ +class AgentOptionReceiveException : Exception { + constructor(message: String?) : super(message) + constructor(message: String?, cause: Throwable?) : super(message, cause) +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.kt new file mode 100644 index 000000000..dcea6074e --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ConfigurationViaTeamscale.kt @@ -0,0 +1,162 @@ +package com.teamscale.jacoco.agent.configuration + +import com.fasterxml.jackson.core.JsonProcessingException +import com.teamscale.client.ITeamscaleService +import com.teamscale.client.JsonUtils +import com.teamscale.client.ProcessInformation +import com.teamscale.client.ProfilerConfiguration +import com.teamscale.client.ProfilerInfo +import com.teamscale.client.ProfilerRegistration +import com.teamscale.client.TeamscaleServiceGenerator +import com.teamscale.jacoco.agent.logging.LoggingUtils +import com.teamscale.jacoco.agent.util.AgentUtils +import com.teamscale.report.util.ILogger +import okhttp3.HttpUrl +import okhttp3.ResponseBody +import retrofit2.Response +import java.io.IOException +import java.time.Duration +import java.util.concurrent.Executors +import java.util.concurrent.ThreadFactory +import java.util.concurrent.TimeUnit + +/** + * Responsible for holding the configuration retrieved from Teamscale and sending regular heartbeat events to + * keep the profiler information in Teamscale up to date. + */ +class ConfigurationViaTeamscale( + private val teamscaleClient: ITeamscaleService, + profilerRegistration: ProfilerRegistration, + processInformation: ProcessInformation +) { + /** + * The UUID that Teamscale assigned to this instance of the profiler during the registration. This ID needs to be + * used when communicating with Teamscale. + */ + @JvmField + val profilerId = profilerRegistration.profilerId + + private val profilerInfo = ProfilerInfo(processInformation, profilerRegistration.profilerConfiguration) + + /** Returns the profiler configuration retrieved from Teamscale. */ + val profilerConfiguration: ProfilerConfiguration? + get() = profilerInfo.profilerConfiguration + + /** + * Starts a heartbeat thread and registers a shutdown hook. + * + * + * This spawns a new thread every minute which sends a heartbeat to Teamscale. It also registers a shutdown hook + * that unregisters the profiler from Teamscale. + */ + fun startHeartbeatThreadAndRegisterShutdownHook() { + val executor = Executors.newSingleThreadScheduledExecutor { runnable -> + val thread = Thread(runnable) + thread.setDaemon(true) + thread + } + + executor.scheduleAtFixedRate({ sendHeartbeat() }, 1, 1, TimeUnit.MINUTES) + + Runtime.getRuntime().addShutdownHook(Thread { + executor.shutdownNow() + unregisterProfiler() + }) + } + + private fun sendHeartbeat() { + try { + val response = teamscaleClient.sendHeartbeat(profilerId!!, profilerInfo).execute() + if (!response.isSuccessful) { + LoggingUtils.getLogger(this) + .error("Failed to send heartbeat. Teamscale responded with: ${response.errorBody()?.string()}") + } + } catch (e: IOException) { + LoggingUtils.getLogger(this).error("Failed to send heartbeat to Teamscale!", e) + } + } + + /** Unregisters the profiler in Teamscale (marks it as shut down). */ + fun unregisterProfiler() { + try { + var response = teamscaleClient.unregisterProfiler(profilerId!!).execute() + if (response.code() == 405) { + response = teamscaleClient.unregisterProfilerLegacy(profilerId).execute() + } + if (!response.isSuccessful) { + LoggingUtils.getLogger(this) + .error("Failed to unregister profiler. Teamscale responded with: ${response.errorBody()?.string()}") + } + } catch (e: IOException) { + LoggingUtils.getLogger(this).error("Failed to unregister profiler!", e) + } + } + + companion object { + /** + * Two minute timeout. This is quite high to account for an eventual high load on the Teamscale server. This is a + * tradeoff between fast application startup and potentially missing test coverage. + */ + private val LONG_TIMEOUT: Duration = Duration.ofMinutes(2) + + /** + * Tries to retrieve the profiler configuration from Teamscale. In case retrieval fails the method throws a + * [AgentOptionReceiveException]. + */ + @JvmStatic + @Throws(AgentOptionReceiveException::class) + fun retrieve( + logger: ILogger, + configurationId: String?, + url: HttpUrl, + userName: String, + userAccessToken: String + ): ConfigurationViaTeamscale { + val teamscaleClient = TeamscaleServiceGenerator + .createService(ITeamscaleService::class.java, url, userName, userAccessToken, AgentUtils.USER_AGENT, LONG_TIMEOUT, LONG_TIMEOUT) + try { + val processInformation = ProcessInformationRetriever(logger).processInformation + val response = teamscaleClient.registerProfiler( + configurationId, + processInformation + ).execute() + if (!response.isSuccessful) { + throw AgentOptionReceiveException( + "Failed to retrieve profiler configuration from Teamscale due to failed request. Http status: ${response.code()} Body: ${response.errorBody()?.string()}" + ) + } + + val body = response.body() + return parseProfilerRegistration(body!!, response, teamscaleClient, processInformation) + } catch (e: IOException) { + // we include the causing error message in this exception's message since this causes it to be printed + // to stderr which is much more helpful than just saying "something didn't work" + throw AgentOptionReceiveException( + "Failed to retrieve profiler configuration from Teamscale due to network error: ${ + LoggingUtils.getStackTraceAsString(e) + }", e + ) + } + } + + @Throws(AgentOptionReceiveException::class, IOException::class) + private fun parseProfilerRegistration( + body: ResponseBody, + response: Response, + teamscaleClient: ITeamscaleService, + processInformation: ProcessInformation + ): ConfigurationViaTeamscale { + // We may only call this once + val bodyString = body.string() + try { + val registration = JsonUtils.deserialize(bodyString) + return ConfigurationViaTeamscale(teamscaleClient, registration, processInformation) + } catch (e: JsonProcessingException) { + throw AgentOptionReceiveException( + "Failed to retrieve profiler configuration from Teamscale due to invalid JSON. HTTP code: " + response.code() + " Response: " + bodyString, + e + ) + } + } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ProcessInformationRetriever.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ProcessInformationRetriever.kt new file mode 100644 index 000000000..e4692acdd --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/configuration/ProcessInformationRetriever.kt @@ -0,0 +1,53 @@ +package com.teamscale.jacoco.agent.configuration + +import com.teamscale.client.ProcessInformation +import com.teamscale.report.util.ILogger +import java.lang.management.ManagementFactory +import java.net.InetAddress +import java.net.UnknownHostException + +/** + * Is responsible for retrieving process information such as the host name and process ID. + */ +class ProcessInformationRetriever(private val logger: ILogger) { + /** + * Retrieves the process information, including the host name and process ID. + */ + val processInformation: ProcessInformation + get() = ProcessInformation(hostName, pID, System.currentTimeMillis()) + + /** + * Retrieves the host name of the local machine. + */ + private val hostName: String + get() { + try { + return InetAddress.getLocalHost().hostName + } catch (e: UnknownHostException) { + logger.error("Failed to determine hostname!", e) + return "" + } + } + + /** + * Returns a string that *probably* contains the PID. + * + * On Java 9 there is an API to get the PID. But since we support Java 8, we may fall back to an undocumented API + * that at least contains the PID in most JVMs. + * + * See [This StackOverflow question](https://stackoverflow.com/questions/35842/how-can-a-java-program-get-its-own-process-id) + */ + companion object { + val pID: String + get() { + try { + val processHandleClass = Class.forName("java.lang.ProcessHandle") + val processHandle = processHandleClass.getMethod("current").invoke(null) + val pid = processHandleClass.getMethod("pid").invoke(processHandle) as Long + return pid.toString() + } catch (_: ReflectiveOperationException) { + return ManagementFactory.getRuntimeMXBean().name + } + } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/ConvertCommand.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/ConvertCommand.kt new file mode 100644 index 000000000..2183de6f5 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/ConvertCommand.kt @@ -0,0 +1,156 @@ +/*-------------------------------------------------------------------------+ +| | +| Copyright (c) 2009-2017 CQSE GmbH | +| | ++-------------------------------------------------------------------------*/ +package com.teamscale.jacoco.agent.convert + +import com.beust.jcommander.Parameter +import com.beust.jcommander.Parameters +import com.teamscale.client.FileSystemUtils.ensureDirectoryExists +import com.teamscale.client.StringUtils.isEmpty +import com.teamscale.jacoco.agent.commandline.ICommand +import com.teamscale.jacoco.agent.commandline.Validator +import com.teamscale.jacoco.agent.options.ClasspathUtils +import com.teamscale.jacoco.agent.options.FilePatternResolver +import com.teamscale.jacoco.agent.util.Assertions +import com.teamscale.report.EDuplicateClassFileBehavior +import com.teamscale.report.util.CommandLineLogger +import java.io.File +import java.io.IOException + +/** + * Encapsulates all command line options for the convert command for parsing with [JCommander]. + */ +@Parameters( + commandNames = ["convert"], commandDescription = "Converts a binary .exec coverage file to XML. " + + "Note that the XML report will only contain source file coverage information, but no class coverage." +) +class ConvertCommand : ICommand { + /** The directories and/or zips that contain all class files being profiled. */ + @JvmField + @Parameter( + names = ["--class-dir", "--jar", "-c"], required = true, description = ("" + + "The directories or zip/ear/jar/war/... files that contain the compiled Java classes being profiled." + + " Searches recursively, including inside zips. You may also supply a *.txt file with one path per line.") + ) + var classDirectoriesOrZips = mutableListOf() + + /** + * Wildcard include patterns to apply during JaCoCo's traversal of class files. + */ + @Parameter( + names = ["--includes"], description = ("" + + "Wildcard include patterns to apply to all found class file locations during JaCoCo's traversal of class files." + + " Note that zip contents are separated from zip files with @ and that you can filter only" + + " class files, not intermediate folders/zips. Use with great care as missing class files" + + " lead to broken coverage files! Turn on debug logging to see which locations are being filtered." + + " Defaults to no filtering. Excludes overrule includes.") + ) + var locationIncludeFilters = mutableListOf() + + /** + * Wildcard exclude patterns to apply during JaCoCo's traversal of class files. + */ + @Parameter( + names = ["--excludes", "-e"], description = ("" + + "Wildcard exclude patterns to apply to all found class file locations during JaCoCo's traversal of class files." + + " Note that zip contents are separated from zip files with @ and that you can filter only" + + " class files, not intermediate folders/zips. Use with great care as missing class files" + + " lead to broken coverage files! Turn on debug logging to see which locations are being filtered." + + " Defaults to no filtering. Excludes overrule includes.") + ) + var locationExcludeFilters = mutableListOf() + + /** The directory to write the XML traces to. */ + @JvmField + @Parameter( + names = ["--in", "-i"], required = true, description = ("" + "The binary .exec file(s), test details and " + + "test executions to read. Can be a single file or a directory that is recursively scanned for relevant files.") + ) + var inputFiles = mutableListOf() + + /** The directory to write the XML traces to. */ + @JvmField + @Parameter( + names = ["--out", "-o"], required = true, description = ("" + + "The file to write the generated XML report to.") + ) + var outputFile = "" + + /** Whether to ignore duplicate, non-identical class files. */ + @Parameter( + names = ["--duplicates", "-d"], arity = 1, description = ("" + + "Whether to ignore duplicate, non-identical class files." + + " This is discouraged and may result in incorrect coverage files. Defaults to WARN. " + + "Options are FAIL, WARN and IGNORE.") + ) + var duplicateClassFileBehavior = EDuplicateClassFileBehavior.WARN + + /** Whether to ignore uncovered class files. */ + @Parameter( + names = ["--ignore-uncovered-classes"], required = false, arity = 1, description = ("" + + "Whether to ignore uncovered classes." + + " These classes will not be part of the XML report at all, making it considerably smaller in some cases. Defaults to false.") + ) + var shouldIgnoreUncoveredClasses = false + + /** Whether testwise coverage or jacoco coverage should be generated. */ + @Parameter( + names = ["--testwise-coverage", "-t"], required = false, arity = 0, description = "Whether testwise " + + "coverage or jacoco coverage should be generated." + ) + var shouldGenerateTestwiseCoverage = false + + /** After how many tests testwise coverage should be split into multiple reports. */ + @Parameter( + names = ["--split-after", "-s"], required = false, arity = 1, description = "After how many tests " + + "testwise coverage should be split into multiple reports (Default is 5000)." + ) + val splitAfter = 5000 + + @Throws(IOException::class) + fun getClassDirectoriesOrZips(): List = ClasspathUtils + .resolveClasspathTextFiles( + "class-dir", FilePatternResolver(CommandLineLogger()), + classDirectoriesOrZips + ) + + fun getInputFiles() = inputFiles.map { File(it) } + fun getOutputFile() = File(outputFile) + + /** Makes sure the arguments are valid. */ + override fun validate() = Validator().apply { + val classDirectoriesOrZips = mutableListOf() + ensure { classDirectoriesOrZips.addAll(getClassDirectoriesOrZips()) } + isFalse( + classDirectoriesOrZips.isEmpty(), + "You must specify at least one directory or zip that contains class files" + ) + classDirectoriesOrZips.forEach { path -> + isTrue(path.exists(), "Path '$path' does not exist") + isTrue(path.canRead(), "Path '$path' is not readable") + } + getInputFiles().forEach { inputFile -> + isTrue(inputFile.exists() && inputFile.canRead(), "Cannot read the input file $inputFile") + } + ensure { + Assertions.isFalse(isEmpty(outputFile), "You must specify an output file") + val outputDir = getOutputFile().getAbsoluteFile().getParentFile() + ensureDirectoryExists(outputDir) + Assertions.isTrue(outputDir.canWrite(), "Path '$outputDir' is not writable") + } + } + + /** {@inheritDoc} */ + @Throws(Exception::class) + override fun run() { + Converter(this).apply { + if (shouldGenerateTestwiseCoverage) { + runTestwiseCoverageReportGeneration() + } else { + runJaCoCoReportGeneration() + } + } + } +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/Converter.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/Converter.kt new file mode 100644 index 000000000..0506d8acf --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/convert/Converter.kt @@ -0,0 +1,99 @@ +package com.teamscale.jacoco.agent.convert + +import com.teamscale.client.TestDetails +import com.teamscale.jacoco.agent.benchmark +import com.teamscale.jacoco.agent.logging.LoggingUtils +import com.teamscale.jacoco.agent.options.AgentOptionParseException +import com.teamscale.jacoco.agent.util.Benchmark +import com.teamscale.report.ReportUtils +import com.teamscale.report.ReportUtils.listFiles +import com.teamscale.report.jacoco.EmptyReportException +import com.teamscale.report.jacoco.JaCoCoXmlReportGenerator +import com.teamscale.report.testwise.ETestArtifactFormat +import com.teamscale.report.testwise.TestwiseCoverageReportWriter +import com.teamscale.report.testwise.jacoco.JaCoCoTestwiseReportGenerator +import com.teamscale.report.testwise.model.TestExecution +import com.teamscale.report.testwise.model.factory.TestInfoFactory +import com.teamscale.report.util.ClasspathWildcardIncludeFilter +import com.teamscale.report.util.CommandLineLogger +import com.teamscale.report.util.ILogger +import java.io.File +import java.io.IOException +import java.lang.String +import java.nio.file.Paths +import kotlin.Array +import kotlin.Throws +import kotlin.use + +/** Converts one .exec binary coverage file to XML. */ +class Converter +/** Constructor. */( + /** The command line arguments. */ + private val arguments: ConvertCommand +) { + /** Converts one .exec binary coverage file to XML. */ + @Throws(IOException::class) + fun runJaCoCoReportGeneration() { + val logger = LoggingUtils.getLogger(this) + val generator = JaCoCoXmlReportGenerator( + arguments.getClassDirectoriesOrZips(), + wildcardIncludeExcludeFilter, + arguments.duplicateClassFileBehavior, + arguments.shouldIgnoreUncoveredClasses, + LoggingUtils.wrap(logger) + ) + + val jacocoExecutionDataList = listFiles(ETestArtifactFormat.JACOCO, arguments.getInputFiles()) + try { + benchmark("Generating the XML report") { + generator.convertExecFilesToReport(jacocoExecutionDataList, Paths.get(arguments.outputFile).toFile()) + } + } catch (e: EmptyReportException) { + logger.warn("Converted report was empty.", e) + } + } + + /** Converts one .exec binary coverage file, test details and test execution files to JSON testwise coverage. */ + @Throws(IOException::class, AgentOptionParseException::class) + fun runTestwiseCoverageReportGeneration() { + val testDetails = ReportUtils.readObjects( + ETestArtifactFormat.TEST_LIST, + Array::class.java, + arguments.getInputFiles() + ) + val testExecutions = ReportUtils.readObjects( + ETestArtifactFormat.TEST_EXECUTION, + Array::class.java, + arguments.getInputFiles() + ) + + val jacocoExecutionDataList = listFiles(ETestArtifactFormat.JACOCO, arguments.getInputFiles()) + val logger = CommandLineLogger() + + val generator = JaCoCoTestwiseReportGenerator( + arguments.getClassDirectoriesOrZips(), + this.wildcardIncludeExcludeFilter, + arguments.duplicateClassFileBehavior, + logger + ) + + benchmark("Generating the testwise coverage report") { + logger.info("Writing report with ${testDetails.size} Details/${testExecutions.size} Results") + TestwiseCoverageReportWriter( + TestInfoFactory(testDetails, testExecutions), + arguments.getOutputFile(), + arguments.splitAfter, null + ).use { coverageWriter -> + jacocoExecutionDataList.forEach { executionDataFile -> + generator.convertAndConsume(executionDataFile, coverageWriter) + } + } + } + } + + private val wildcardIncludeExcludeFilter: ClasspathWildcardIncludeFilter + get() = ClasspathWildcardIncludeFilter( + String.join(":", arguments.locationIncludeFilters), + String.join(":", arguments.locationExcludeFilters) + ) +} diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/DebugLogDirectoryPropertyDefiner.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/DebugLogDirectoryPropertyDefiner.kt new file mode 100644 index 000000000..876e989f2 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/DebugLogDirectoryPropertyDefiner.kt @@ -0,0 +1,14 @@ +package com.teamscale.jacoco.agent.logging + +import java.nio.file.Path + +/** Defines a property that contains the path to which log files should be written. */ +class DebugLogDirectoryPropertyDefiner : LogDirectoryPropertyDefiner() { + override fun getPropertyValue() = + filePath?.resolve("logs")?.toAbsolutePath()?.toString() ?: super.getPropertyValue() + + companion object { + /** File path for debug logging. */ /* package */ + var filePath: Path? = null + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogDirectoryPropertyDefiner.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogDirectoryPropertyDefiner.kt new file mode 100644 index 000000000..c587a45f0 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogDirectoryPropertyDefiner.kt @@ -0,0 +1,10 @@ +package com.teamscale.jacoco.agent.logging + +import ch.qos.logback.core.PropertyDefinerBase +import com.teamscale.jacoco.agent.util.AgentUtils + +/** Defines a property that contains the default path to which log files should be written. */ +open class LogDirectoryPropertyDefiner : PropertyDefinerBase() { + override fun getPropertyValue() = + AgentUtils.mainTempDirectory.resolve("logs").toAbsolutePath().toString() +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt new file mode 100644 index 000000000..f646f9848 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LogToTeamscaleAppender.kt @@ -0,0 +1,183 @@ +package com.teamscale.jacoco.agent.logging + +import ch.qos.logback.classic.Logger +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.core.AppenderBase +import ch.qos.logback.core.status.ErrorStatus +import com.teamscale.client.ITeamscaleService +import com.teamscale.client.ProfilerLogEntry +import com.teamscale.jacoco.agent.options.AgentOptions +import java.net.ConnectException +import java.time.Duration +import java.util.Collections +import java.util.IdentityHashMap +import java.util.LinkedHashSet +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.function.BiConsumer + +/** + * Custom log appender that sends logs to Teamscale; it buffers log that were not sent due to connection issues and + * sends them later. + */ +class LogToTeamscaleAppender : AppenderBase() { + /** The unique ID of the profiler */ + private var profilerId: String? = null + + /** + * Buffer for unsent logs. We use a set here to allow for removing entries fast after sending them to Teamscale was + * successful. + */ + private val logBuffer = LinkedHashSet() + + /** Scheduler for sending logs after the configured time interval */ + private val scheduler: ScheduledExecutorService = Executors.newScheduledThreadPool(1) { r -> + // Make the thread a daemon so that it does not prevent the JVM from terminating. + val t = Executors.defaultThreadFactory().newThread(r) + t.setDaemon(true) + t + } + + /** Active log flushing threads */ + private val activeLogFlushes: MutableSet> = + Collections.newSetFromMap(IdentityHashMap()) + + /** Is there a flush going on right now? */ + private val isFlusing = AtomicBoolean(false) + + override fun start() { + super.start() + scheduler.scheduleAtFixedRate({ + synchronized(activeLogFlushes) { + activeLogFlushes.removeIf { it.isDone } + if (activeLogFlushes.isEmpty()) flush() + } + }, FLUSH_INTERVAL.toMillis(), FLUSH_INTERVAL.toMillis(), TimeUnit.MILLISECONDS) + } + + override fun append(eventObject: ILoggingEvent) { + synchronized(logBuffer) { + logBuffer.add(formatLog(eventObject)) + if (logBuffer.size >= BATCH_SIZE) flush() + } + } + + private fun formatLog(eventObject: ILoggingEvent): ProfilerLogEntry { + val trace = LoggingUtils.getStackTraceFromEvent(eventObject) + val timestamp = eventObject.timeStamp + val message = eventObject.formattedMessage + val severity = eventObject.level.toString() + return ProfilerLogEntry(timestamp, message, trace, severity) + } + + private fun flush() { + sendLogs() + } + + /** Send logs in a separate thread */ + private fun sendLogs() { + synchronized(activeLogFlushes) { + activeLogFlushes.add(CompletableFuture.runAsync { + if (isFlusing.compareAndSet(false, true)) { + try { + val client = teamscaleClient ?: return@runAsync // There might be no connection configured. + + val logsToSend: MutableList + synchronized(logBuffer) { + logsToSend = logBuffer.toMutableList() + } + + val call = client.postProfilerLog(profilerId!!, logsToSend) + val response = call.execute() + check(response.isSuccessful) { "Failed to send log: HTTP error code : ${response.code()}" } + + synchronized(logBuffer) { + // Removing the logs that have been sent after the fact. + // This handles problems with lost network connections. + logBuffer.removeAll(logsToSend.toSet()) + } + } catch (e: Exception) { + // We do not report on exceptions here. + if (e !is ConnectException) { + addStatus(ErrorStatus("Sending logs to Teamscale failed: ${e.message}", this, e)) + } + } finally { + isFlusing.set(false) + } + } + }.whenComplete(BiConsumer { _, _ -> + synchronized(activeLogFlushes) { + activeLogFlushes.removeIf { it.isDone } + } + })) + } + } + + override fun stop() { + // Already flush here once to make sure that we do not miss too much. + flush() + + scheduler.shutdown() + try { + if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { + scheduler.shutdownNow() + } + } catch (_: InterruptedException) { + scheduler.shutdownNow() + } + + // A final flush after the scheduler has been shut down. + flush() + + // Block until all flushes are done + CompletableFuture.allOf(*activeLogFlushes.toTypedArray()).join() + + super.stop() + } + + fun setTeamscaleClient(teamscaleClient: ITeamscaleService?) { + Companion.teamscaleClient = teamscaleClient + } + + fun setProfilerId(profilerId: String) { + this.profilerId = profilerId + } + + companion object { + /** Flush the logs after N elements are in the queue */ + private const val BATCH_SIZE = 50 + + /** Flush the logs in the given time interval */ + private val FLUSH_INTERVAL: Duration = Duration.ofSeconds(3) + + /** The service client for sending logs to Teamscale */ + private var teamscaleClient: ITeamscaleService? = null + + /** + * Add the [LogToTeamscaleAppender] to the logging configuration and + * enable/start it. + */ + fun addTeamscaleAppenderTo(context: LoggerContext, agentOptions: AgentOptions): Boolean { + val client = agentOptions.createTeamscaleClient(false) + if (client == null || agentOptions.configurationViaTeamscale == null) { + return false + } + + context.getLogger(Logger.ROOT_LOGGER_NAME).apply { + val logToTeamscaleAppender = LogToTeamscaleAppender().apply { + setContext(context) + setProfilerId(agentOptions.configurationViaTeamscale.profilerId!!) + setTeamscaleClient(client.service) + start() + } + addAppender(logToTeamscaleAppender) + } + + return true + } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LoggingUtils.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LoggingUtils.kt new file mode 100644 index 000000000..0e0a244a9 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/logging/LoggingUtils.kt @@ -0,0 +1,124 @@ +package com.teamscale.jacoco.agent.logging + +import ch.qos.logback.classic.LoggerContext +import ch.qos.logback.classic.joran.JoranConfigurator +import ch.qos.logback.classic.spi.ILoggingEvent +import ch.qos.logback.classic.spi.ThrowableProxy +import ch.qos.logback.classic.spi.ThrowableProxyUtil +import ch.qos.logback.core.joran.spi.JoranException +import ch.qos.logback.core.util.StatusPrinter +import com.teamscale.jacoco.agent.Agent +import com.teamscale.jacoco.agent.util.NullOutputStream +import com.teamscale.report.util.ILogger +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import java.io.FileInputStream +import java.io.IOException +import java.io.InputStream +import java.io.PrintStream +import java.lang.AutoCloseable +import java.nio.file.Path + +/** + * Helps initialize the logging framework properly. + */ +object LoggingUtils { + /** Returns a logger for the given object's class. */ + @JvmStatic + fun getLogger(obj: Any): Logger = LoggerFactory.getLogger(obj.javaClass) + + /** Returns a logger for the given class. */ + @JvmStatic + fun getLogger(obj: Class<*>): Logger = LoggerFactory.getLogger(obj) + + /** Initializes the logging to the default configured in the Jar. */ + fun initializeDefaultLogging(): LoggingResources { + val stream = Agent::class.java.getResourceAsStream("logback-default.xml") + reconfigureLoggerContext(stream) + return LoggingResources() + } + + /** + * Returns the logger context. + */ + val loggerContext: LoggerContext + get() = LoggerFactory.getILoggerFactory() as LoggerContext + + /** + * Extracts the stack trace from an ILoggingEvent using ThrowableProxyUtil. + * + * @param event the logging event containing the exception + * @return the stack trace as a String, or null if no exception is associated + */ + fun getStackTraceFromEvent(event: ILoggingEvent) = + event.throwableProxy?.let { ThrowableProxyUtil.asString(it) } + + /** + * Converts a Throwable to its stack trace as a String. + * + * @param throwable the throwable to convert + * @return the stack trace as a String + */ + @JvmStatic + fun getStackTraceAsString(throwable: Throwable?) = + throwable?.let { ThrowableProxyUtil.asString(ThrowableProxy(it)) } + + /** + * Reconfigures the logger context to use the configuration XML from the given input stream. Cf. [https://logback.qos.ch/manual/configuration.html](https://logback.qos.ch/manual/configuration.html) + */ + private fun reconfigureLoggerContext(stream: InputStream?) { + StatusPrinter.setPrintStream(PrintStream(NullOutputStream())) + try { + val configurator = JoranConfigurator() + configurator.setContext(loggerContext) + loggerContext.reset() + configurator.doConfigure(stream) + } catch (_: JoranException) { + // StatusPrinter will handle this + } + StatusPrinter.printInCaseOfErrorsOrWarnings(loggerContext) + } + + /** + * Initializes the logging from the given file. If that is `null`, uses [ ][.initializeDefaultLogging] instead. + */ + @Throws(IOException::class) + fun initializeLogging(loggingConfigFile: Path?): LoggingResources { + if (loggingConfigFile == null) { + return initializeDefaultLogging() + } + + reconfigureLoggerContext(FileInputStream(loggingConfigFile.toFile())) + return LoggingResources() + } + + /** Initializes debug logging. */ + fun initializeDebugLogging(logDirectory: Path?): LoggingResources { + if (logDirectory != null) { + DebugLogDirectoryPropertyDefiner.filePath = logDirectory + } + val stream = Agent::class.java.getResourceAsStream("logback-default-debugging.xml") + reconfigureLoggerContext(stream) + return LoggingResources() + } + + /** Wraps the given slf4j logger into an [com.teamscale.report.util.ILogger]. */ + @JvmStatic + fun wrap(logger: Logger): ILogger { + return object : ILogger { + override fun debug(message: String) = logger.debug(message) + override fun info(message: String) = logger.info(message) + override fun warn(message: String) = logger.warn(message) + override fun warn(message: String, throwable: Throwable?) = logger.warn(message, throwable) + override fun error(throwable: Throwable) = logger.error(throwable.message, throwable) + override fun error(message: String, throwable: Throwable?) = logger.error(message, throwable) + } + } + + /** Class to use with try-with-resources to close the logging framework's resources. */ + class LoggingResources : AutoCloseable { + override fun close() { + loggerContext.stop() + } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/AgentUtils.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/AgentUtils.kt new file mode 100644 index 000000000..6f5eb0d84 --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/AgentUtils.kt @@ -0,0 +1,57 @@ +package com.teamscale.jacoco.agent.util + +import com.teamscale.client.FileSystemUtils +import com.teamscale.client.TeamscaleServiceGenerator +import com.teamscale.jacoco.agent.PreMain +import com.teamscale.jacoco.agent.configuration.ProcessInformationRetriever +import java.io.IOException +import java.net.URISyntaxException +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.* + +/** General utilities for working with the agent. */ +object AgentUtils { + /** Version of this program. */ + val VERSION: String + + /** User-Agent header value for HTTP requests. */ + @JvmField + val USER_AGENT: String + + /** + * Returns the main temporary directory where all agent temp files should be placed. + */ + @JvmStatic + val mainTempDirectory: Path by lazy { + try { + // We add a trailing hyphen here to visually separate the PID from the random number that Java appends + // to the name to make it unique + Files.createTempDirectory( + "teamscale-java-profiler-${FileSystemUtils.toSafeFilename(ProcessInformationRetriever.pID)}-" + ) + } catch (e: IOException) { + throw RuntimeException("Failed to create temporary directory for agent files", e) + } + } + + /** Returns the directory that contains the agent installation. */ + @JvmStatic + val agentDirectory: Path by lazy { + try { + val jarFileUri = PreMain::class.java.getProtectionDomain().codeSource.location.toURI() + // we assume that the dist zip is extracted and the agent jar not moved + val jarDirectory = Paths.get(jarFileUri).parent + jarDirectory.parent ?: jarDirectory // happens when the jar file is stored in the root directory + } catch (e: URISyntaxException) { + throw RuntimeException("Failed to obtain agent directory. This is a bug, please report it.", e) + } + } + + init { + val bundle = ResourceBundle.getBundle("com.teamscale.jacoco.agent.app") + VERSION = bundle.getString("version") + USER_AGENT = TeamscaleServiceGenerator.buildUserAgent("Teamscale Java Profiler", VERSION) + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/Assertions.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/Assertions.kt new file mode 100644 index 000000000..ce9b0e44f --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/Assertions.kt @@ -0,0 +1,48 @@ +package com.teamscale.jacoco.agent.util + +import org.jetbrains.annotations.Contract + +/** + * Simple methods to implement assertions. + */ +object Assertions { + /** + * Checks if a condition is `true`. + * + * @param condition condition to check + * @param message exception message + * @throws AssertionError if the condition is `false` + */ + @JvmStatic + @Contract(value = "false, _ -> fail", pure = true) + @Throws(AssertionError::class) + fun isTrue(condition: Boolean, message: String?) { + throwAssertionErrorIfTestFails(condition, message) + } + + /** + * Checks if a condition is `false`. + * + * @param condition condition to check + * @param message exception message + * @throws AssertionError if the condition is `true` + */ + @Contract(value = "true, _ -> fail", pure = true) + @Throws(AssertionError::class) + fun isFalse(condition: Boolean, message: String?) { + throwAssertionErrorIfTestFails(!condition, message) + } + + /** + * Throws an [AssertionError] if the test fails. + * + * @param test test which should be true + * @param message exception message + * @throws AssertionError if the test fails + */ + private fun throwAssertionErrorIfTestFails(test: Boolean, message: String?) { + if (!test) { + throw AssertionError(message) + } + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/DaemonThreadFactory.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/DaemonThreadFactory.kt new file mode 100644 index 000000000..ac4beb65b --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/DaemonThreadFactory.kt @@ -0,0 +1,15 @@ +package com.teamscale.jacoco.agent.util + +import java.util.concurrent.ThreadFactory + +/** + * [java.util.concurrent.ThreadFactory] that only produces deamon threads (threads that don't prevent JVM shutdown) with a fixed name. + */ +class DaemonThreadFactory(owningClass: Class<*>, threadName: String?) : ThreadFactory { + private val threadName = "Teamscale Java Profiler ${owningClass.getSimpleName()} $threadName" + + override fun newThread(runnable: Runnable) = + Thread(runnable, threadName).apply { + setDaemon(true) + } +} \ No newline at end of file diff --git a/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/NullOutputStream.kt b/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/NullOutputStream.kt new file mode 100644 index 000000000..6f15a8e6b --- /dev/null +++ b/agent/src/main/kotlin/com/teamscale/jacoco/agent/util/NullOutputStream.kt @@ -0,0 +1,20 @@ +package com.teamscale.jacoco.agent.util + +import java.io.IOException +import java.io.OutputStream + +/** NOP output stream implementation. */ +class NullOutputStream : OutputStream() { + override fun write(b: ByteArray, off: Int, len: Int) { + // to /dev/null + } + + override fun write(b: Int) { + // to /dev/null + } + + @Throws(IOException::class) + override fun write(b: ByteArray) { + // to /dev/null + } +} \ No newline at end of file diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/ITeamscaleService.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/ITeamscaleService.kt index 314283eab..c7e52e018 100644 --- a/teamscale-client/src/main/kotlin/com/teamscale/client/ITeamscaleService.kt +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/ITeamscaleService.kt @@ -159,7 +159,7 @@ interface ITeamscaleService { @POST("api/v2024.7.0/profilers/{profilerId}/logs") fun postProfilerLog( @Path("profilerId") profilerId: String, - @Body logEntries: List? + @Body logEntries: List? ): Call } diff --git a/teamscale-client/src/main/kotlin/com/teamscale/client/StringUtils.kt b/teamscale-client/src/main/kotlin/com/teamscale/client/StringUtils.kt index 64800f45d..3e2f1169d 100644 --- a/teamscale-client/src/main/kotlin/com/teamscale/client/StringUtils.kt +++ b/teamscale-client/src/main/kotlin/com/teamscale/client/StringUtils.kt @@ -139,7 +139,7 @@ object StringUtils { * list is returned. */ @JvmStatic - fun splitLinesAsList(content: String?): List = content?.lines() ?: emptyList() + fun splitLinesAsList(content: String?) = content?.lines() ?: emptyList() /** * Test if a string ends with one of the provided suffixes. Returns @@ -147,18 +147,14 @@ object StringUtils { * for short lists of suffixes. */ @JvmStatic - fun endsWithOneOf(string: String, vararg suffixes: String): Boolean { - return suffixes.any { string.endsWith(it) } - } + fun endsWithOneOf(string: String, vararg suffixes: String) = suffixes.any { string.endsWith(it) } /** * Removes double quotes from beginning and end (if present) and returns the new * string. */ @JvmStatic - fun removeDoubleQuotes(string: String): String { - return string.removeSuffix("\"").removePrefix("\"") - } + fun removeDoubleQuotes(string: String) = string.removeSuffix("\"").removePrefix("\"") /** * Converts an empty string to null. If the input string is not empty, it returns the string unmodified. @@ -167,9 +163,7 @@ object StringUtils { * @return `null` if the input string is empty after trimming; the original string otherwise. */ @JvmStatic - fun emptyToNull(string: String): String? { - return if (isEmpty(string)) null else string - } + fun emptyToNull(string: String) = if (isEmpty(string)) null else string /** * Converts a nullable string to a non-null, empty string. @@ -179,7 +173,5 @@ object StringUtils { * @return a non-null string; either the original string or an empty string if the input was null */ @JvmStatic - fun nullToEmpty(stringOrNull: String?): String { - return stringOrNull ?: EMPTY_STRING - } + fun nullToEmpty(stringOrNull: String?) = stringOrNull ?: EMPTY_STRING }