All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.appland.appmap.record.Recorder Maven / Gradle / Ivy

The newest version!
package com.appland.appmap.record;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Stack;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;

import com.appland.appmap.config.AppMapConfig;
import com.appland.appmap.output.v1.CodeObject;
import com.appland.appmap.output.v1.Event;
import com.appland.appmap.util.Logger;
import org.apache.commons.lang3.RandomStringUtils;

/**
 * Keep track of what's going on in the current thread.
 */
class ThreadState {
  // Provides the last event on the current thread, which is used in some cases to
  // update the event post facto.
  private Event lastGlobalEvent;
  private Event lastThreadEvent;

  void setLastGlobalEvent(Event e) {
    lastGlobalEvent = e;
  }

  Event getLastGlobalEvent() {
    return lastGlobalEvent;
  }

  void setLastThreadEvent(Event e) {
    lastThreadEvent = e;
  }

  Event getLastThreadEvent() {
    return lastThreadEvent;
  }

  // Avoid accepting new events on a thread that's already processing an event.
  boolean isProcessing;
  Stack callStack = new Stack<>();
}
/**
 * Recorder is a singleton responsible for managing recording sessions and routing events to any
 * active session. It also maintains a code object tree containing every known package/class/method.
 */
public class Recorder {
  private static final String ERROR_SESSION_PRESENT = "an active recording session already exists";
  private static final String ERROR_NO_SESSION = "there is no active recording session";

  private static final Integer FILENAME_MAX_LENGTH = 255; // Max length of a filename on most filesystems
  private static final Integer HASH_LENGTH = 7; // Arbitrary but Git provides it's a reasonable value
  private static final String APPMAP_SUFFIX = ".appmap.json";

  private static final Recorder instance = new Recorder();

  private final ActiveSession activeSession = new ActiveSession();
  private final CodeObjectTree globalCodeObjects = new CodeObjectTree();
  private final Map threadState = new ConcurrentHashMap<>();

  public static String sanitizeFilename(String filename) {
    String sanitizedFilename = filename.replaceAll("[^a-zA-Z0-9-_]", "_");

    if (sanitizedFilename.length() > FILENAME_MAX_LENGTH - APPMAP_SUFFIX.length()) {
      int part = FILENAME_MAX_LENGTH - APPMAP_SUFFIX.length() - 1 - HASH_LENGTH;
      sanitizedFilename = sanitizedFilename.substring(0, part) + "-" + hashFilename(sanitizedFilename.substring(part));
    }

    return sanitizedFilename + APPMAP_SUFFIX;
  }

  private static String hashFilename(String filename) {
    MessageDigest digest;
    try {
      digest = MessageDigest.getInstance("SHA-256");
    } catch (NoSuchAlgorithmException e) {
      return RandomStringUtils.random(HASH_LENGTH);
    }
    byte[] hash = digest.digest(filename.getBytes(StandardCharsets.UTF_8));
    StringBuilder hexString = new StringBuilder(2 * hash.length);
    for (byte b : hash) {
      String hex = Integer.toHexString(0xff & b);
      if (hex.length() == 1) {
        hexString.append('0');
      }
      hexString.append(hex);
    }
    return hexString.substring(0, HASH_LENGTH);
  }

  /**
   * Data structure for reporting AppMap metadata.
   * These fields map to the 'metadata' section of the AppMap JSON.
   */
  public static class Framework {
    public String name;
    public String version;

    public Framework(String name, String version) {
      this.name = name;
      this.version = version;
    }
  }
  public static class Metadata {
    public String scenarioName;
    public String recorderName;
    public String recorderType;
    public List frameworks = new ArrayList<>();
    public String recordedClassName;
    public String recordedMethodName;
    public String sourceLocation;
    public Boolean testSucceeded;
    public String failureMessage;
    public Integer failureLine; // line where failure occurred in sourceLocation

    public Metadata(String recorderName, String recorderType) {
      this.recorderName = recorderName;
      this.recorderType = recorderType;
    }
  }



  static class ActiveSession {
    // All events get added to the global session
    private RecordingSession globalSession = null;

    // Only events for a specific thread will get added to the thread session
    private static final ThreadLocal threadSession = new ThreadLocal();

    synchronized RecordingSession get() throws ActiveSessionException {
      if (globalSession == null) {
        throw new ActiveSessionException(ERROR_NO_SESSION);
      }

      return globalSession;
    }

    boolean exists() {
      return globalSession != null || threadSession.get() != null;
    }

    synchronized RecordingSession release() throws ActiveSessionException {
      if (globalSession == null) {
        throw new ActiveSessionException(ERROR_NO_SESSION);
      }

      RecordingSession result = globalSession;
      globalSession = null;
      return result;
    }

    synchronized void set(RecordingSession session) throws ActiveSessionException {
      if (globalSession != null) {
        throw new ActiveSessionException(ERROR_SESSION_PRESENT);
      }

      globalSession = session;
    }

    void setThread(RecordingSession session) throws ActiveSessionException {
      if (threadSession.get() != null) {
        throw new ActiveSessionException(ERROR_SESSION_PRESENT);
      }

      threadSession.set(session);
    }

    RecordingSession getThread() throws ActiveSessionException {
      if (threadSession.get() == null) {
        throw new ActiveSessionException(ERROR_NO_SESSION);
      }

      return threadSession.get();
    }

    RecordingSession releaseThread() throws ActiveSessionException {
      RecordingSession ret = getThread();
      threadSession.remove();
      return ret;
    }

    /**
     * Add an event to both the global session and thread's session
     */
    synchronized void addEvent(Event event) {
      addGlobalEvent(event);
      addThreadEvent(event);
    }

    synchronized void addEventUpdate(Event event) {
      if (globalSession != null) {
        globalSession.addEventUpdate(event);
      }
      if (threadSession.get() != null) {
        threadSession.get().addEventUpdate(event);
      }
    }

    synchronized void addGlobalEvent(Event event) {
      if (globalSession != null) {
        globalSession.add(event);
      }
    }

    synchronized void addThreadEvent(Event event) {
      RecordingSession session = threadSession.get();
      if (session != null) {
        session.add(event);
      }
    }
  }

  /**
   * Get the global Recorder instance.
   *
   * @return The global recorder instance
   */
  public static Recorder getInstance() {
    return Recorder.instance;
  }

  private Recorder() {
  }

  /**
   * Start a recording session.
   *
   * @param metadata Recording metadata to be written
   * @throws ActiveSessionException If a session is already in progress
   */
  public void start(Metadata metadata) throws ActiveSessionException {
    RecordingSession session = new RecordingSession(metadata);
    activeSession.set(session);
  }

  public void setThreadSession(RecordingSession session) throws ActiveSessionException {
    activeSession.setThread(session);
  }

  public boolean hasActiveSession() {
    return activeSession.exists();
  }

  public Metadata getMetadata() throws ActiveSessionException {
    return activeSession.get().getMetadata();
  }

  public Recording checkpoint() {
    this.flush();
    return activeSession.get().checkpoint();
  }

  /**
   * Stops the active recording session and obtains the result.
   *
   * @return Recording of the current session.
   * @throws ActiveSessionException If no recording session is in progress or the session cannot be
   *                                stopped.
   */
  public Recording stop() throws ActiveSessionException {
    this.flush();
    return activeSession.release().stop();
  }

  public Recording stopThread() {
    flushThread();
    return activeSession.releaseThread().stop();
  }
  /**
   * Record an {@link Event} to the active session.
   *
   * @param event The event to be recorded.
   */
  public void add(Event event) {
    if (!activeSession.exists()) {
      return;
    }

    ThreadState ts = threadState();

    // We don't want re-entrant events on the same thread.
    if ( ts.isProcessing ) {
      return;
    }

    ts.isProcessing = true;
    try {
      if ( event.event.equals("call") ) {
        if (!ts.callStack.empty() && event.hasPackageName() && AppMapConfig.get().isShallow(event.fqn()) ) {
          Event parent = ts.callStack.peek();
          if ( parent.hasPackageName() && event.packageName().equals(parent.packageName()) ) {
            event.ignore();
          }
        }
        
        event.setStartTime();
        ts.callStack.push(event);
      } else if ( event.event.equals("return") ) {
        if ( ts.callStack.isEmpty() ) {
          Logger.println("Discarding 'return' event because the call stack is empty for this thread");
          return;
        }

        // To whom it may concern:
        //
        // You may be tempted to try and track the caller Event using a local variable in the
        // generated code for each hooked function. It would be cleaner and more reliable than
        // tracking a call stack here. However, due to issues with Javassist and the JVM,
        // I (KEG) was not able to find a way to declare, initialize, set, and pass an Event that would
        // work with exception handling and finally clauses.
        Event caller = ts.callStack.pop();
        event.parentId = caller.id;
        event.threadId = caller.threadId;
        event.measureElapsed(caller);
        // Erase these fields - see comment in Event#functionReturnEvent
        event.definedClass = null;
        event.methodId = null;
        event.isStatic = null;
        if ( caller.ignored() ) {
          event.ignore();
        }
      } else {
        throw new IllegalArgumentException("Event should be 'call' or 'return', got " + event.event);
      }

      Event previousGlobalEvent = ts.getLastGlobalEvent();
      ts.setLastGlobalEvent(event);
      addPreviousEvent(previousGlobalEvent, activeSession::addGlobalEvent);

      Event previousThreadEvent = ts.getLastThreadEvent();
      ts.setLastThreadEvent(event);
      addPreviousEvent(previousThreadEvent, activeSession::addThreadEvent);

    } finally {
      ts.isProcessing = false;
    }
  }

  private void addPreviousEvent(Event previousEvent, Consumer eventAdder) {
    if (previousEvent == null || previousEvent.ignored()) {
      return;
    }

    previousEvent.freeze();
    eventAdder.accept(previousEvent);
  }

  /**
   * Register a {@link CodeObject}, allowing it to propagate to an output's Class Map if referenced
   * in an event.
   *
   * @param codeObject The code object to be registered
   */
  public void registerCodeObject(CodeObject codeObject) {
    synchronized (globalCodeObjects) {
      globalCodeObjects.add(codeObject);
    }
  }

  public CodeObjectTree getRegisteredObjects() {
    return this.globalCodeObjects;
  }

  /**
   * Retrieve the last event (of any kind) recorded for this thread. Only used
   * for testing.
   */
  Event getLastEvent() {
    return threadState().getLastGlobalEvent();
  }

  /**
   * Record the execution of a Runnable and return the scenario data as a String
   */
  public Recording record(Runnable fn) throws ActiveSessionException {
    this.start(new Metadata("java", "process"));
    fn.run();
    return this.stop();
  }

  /**
   * Record the execution of a Runnable and write the scenario to a file
   */
  public void record(String name, Runnable fn) throws ActiveSessionException, IOException {
    final String fileName = sanitizeFilename(name);
    final Metadata metadata = new Metadata("java", "process");
    metadata.scenarioName = name;

    this.start(metadata);
    fn.run();
    Recording recording = this.stop();
    recording.moveTo(fileName);
  }

  // Mockito can't stub methods on the Collection
  // returned by values(), so return an iterator on it instead.
  //
  // And, make this method package-protected, because Mockito won't
  // stub private methods.
  /* private */ Iterator getThreadStateIterator() {
    return this.threadState.values().iterator();
  }

  private ThreadState threadState() {
    ThreadState ts = threadState.get(Thread.currentThread().getId());
    if ( ts == null ) {
      threadState.put(Thread.currentThread().getId(), (ts = new ThreadState()));
    }
    return ts;
  }

  // Finish serializing any remaining events. This is necessary because each event is "open"
  // until the next event on the same thread is received.
  private void flush() {
    getThreadStateIterator().forEachRemaining((ts) -> {
      if (ts.getLastGlobalEvent() == null) {
        return;
      }

      ts.isProcessing = true;
      try {
        Event event = ts.getLastGlobalEvent();
        ts.setLastGlobalEvent(null);

        event.freeze();
        activeSession.addGlobalEvent(event);
        event.defrost();
      } finally {
        ts.isProcessing = false;
      }
    });
  }

  private void flushThread() {
    ThreadState ts = threadState();

    if (ts.getLastThreadEvent() == null) {
      return;
    }

    ts.isProcessing = true;
    try {
      Event event = ts.getLastThreadEvent();
      ts.setLastThreadEvent(null);

      event.freeze();
      activeSession.addThreadEvent(event);
      event.defrost();
    } finally {
      ts.isProcessing = false;
    }
  }

  public void addEventUpdate(Event event) {
    if (!activeSession.exists()) {
      return;
    }
    activeSession.addEventUpdate(event);
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy