diff --git a/Core/nbproject/project.xml b/Core/nbproject/project.xml index 0159ec9880..5dabff9f5d 100644 --- a/Core/nbproject/project.xml +++ b/Core/nbproject/project.xml @@ -327,6 +327,7 @@ org.sleuthkit.autopsy.events org.sleuthkit.autopsy.filesearch org.sleuthkit.autopsy.guiutils + org.sleuthkit.autopsy.healthmonitor org.sleuthkit.autopsy.ingest org.sleuthkit.autopsy.keywordsearchservice org.sleuthkit.autopsy.menuactions diff --git a/Core/src/org/sleuthkit/autopsy/coordinationservice/CoordinationService.java b/Core/src/org/sleuthkit/autopsy/coordinationservice/CoordinationService.java index 9b2afff6b4..d7ef8c4750 100644 --- a/Core/src/org/sleuthkit/autopsy/coordinationservice/CoordinationService.java +++ b/Core/src/org/sleuthkit/autopsy/coordinationservice/CoordinationService.java @@ -457,7 +457,8 @@ public final class CoordinationService { CASES("cases"), MANIFESTS("manifests"), CONFIG("config"), - CENTRAL_REPO("centralRepository"); + CENTRAL_REPO("centralRepository"), + HEALTH_MONITOR("healthMonitor"); private final String displayName; diff --git a/Core/src/org/sleuthkit/autopsy/core/Installer.java b/Core/src/org/sleuthkit/autopsy/core/Installer.java index 83250f719d..63ac880171 100644 --- a/Core/src/org/sleuthkit/autopsy/core/Installer.java +++ b/Core/src/org/sleuthkit/autopsy/core/Installer.java @@ -216,6 +216,7 @@ public class Installer extends ModuleInstall { packageInstallers.add(org.sleuthkit.autopsy.datamodel.Installer.getDefault()); packageInstallers.add(org.sleuthkit.autopsy.ingest.Installer.getDefault()); packageInstallers.add(org.sleuthkit.autopsy.centralrepository.eventlisteners.Installer.getDefault()); + packageInstallers.add(org.sleuthkit.autopsy.healthmonitor.Installer.getDefault()); } /** diff --git a/Core/src/org/sleuthkit/autopsy/healthmonitor/EnterpriseHealthMonitor.java b/Core/src/org/sleuthkit/autopsy/healthmonitor/EnterpriseHealthMonitor.java new file mode 100644 index 0000000000..35bc6b6bfa --- /dev/null +++ b/Core/src/org/sleuthkit/autopsy/healthmonitor/EnterpriseHealthMonitor.java @@ -0,0 +1,785 @@ +/* + * Autopsy Forensic Browser + * + * Copyright 2018 Basis Technology Corp. + * Contact: carrier sleuthkit org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.sleuthkit.autopsy.healthmonitor; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import java.beans.PropertyChangeEvent; +import java.beans.PropertyChangeListener; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Map; +import java.util.HashMap; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import org.apache.commons.dbcp2.BasicDataSource; +import org.sleuthkit.autopsy.casemodule.Case; +import org.sleuthkit.autopsy.coordinationservice.CoordinationService; +import org.sleuthkit.autopsy.core.UserPreferences; +import org.sleuthkit.autopsy.core.UserPreferencesException; +import org.sleuthkit.autopsy.coreutils.Logger; +import org.sleuthkit.autopsy.coreutils.ModuleSettings; +import org.sleuthkit.autopsy.coreutils.ThreadUtils; +import org.sleuthkit.datamodel.CaseDbConnectionInfo; +import org.sleuthkit.datamodel.CaseDbSchemaVersionNumber; + +/** + * Class for recording data on the health of the system. + * + * For timing data: + * Modules will call getTimingMetric() before the code to be timed to get a TimingMetric object + * Modules will call submitTimingMetric() with the obtained TimingMetric object to log it + */ +public final class EnterpriseHealthMonitor implements PropertyChangeListener { + + private final static Logger logger = Logger.getLogger(EnterpriseHealthMonitor.class.getName()); + private final static String DATABASE_NAME = "EnterpriseHealthMonitor"; + private final static String MODULE_NAME = "EnterpriseHealthMonitor"; + private final static String IS_ENABLED_KEY = "is_enabled"; + private final static long DATABASE_WRITE_INTERVAL = 60; // Minutes + public static final CaseDbSchemaVersionNumber CURRENT_DB_SCHEMA_VERSION + = new CaseDbSchemaVersionNumber(1, 0); + + private static final AtomicBoolean isEnabled = new AtomicBoolean(false); + private static EnterpriseHealthMonitor instance; + + private final ExecutorService healthMonitorExecutor; + private static final String HEALTH_MONITOR_EVENT_THREAD_NAME = "Health-Monitor-Event-Listener-%d"; + + private ScheduledThreadPoolExecutor healthMonitorOutputTimer; + private final Map timingInfoMap; + private static final int CONN_POOL_SIZE = 10; + private BasicDataSource connectionPool = null; + private String hostName; + + private EnterpriseHealthMonitor() throws HealthMonitorException { + + // Create the map to collect timing metrics. The map will exist regardless + // of whether the monitor is enabled. + timingInfoMap = new HashMap<>(); + + // Set up the executor to handle case events + healthMonitorExecutor = Executors.newSingleThreadExecutor(new ThreadFactoryBuilder().setNameFormat(HEALTH_MONITOR_EVENT_THREAD_NAME).build()); + + // Get the host name + try { + hostName = java.net.InetAddress.getLocalHost().getHostName(); + } catch (java.net.UnknownHostException ex) { + // Continue on, but log the error and generate a UUID to use for this session + hostName = UUID.randomUUID().toString(); + logger.log(Level.SEVERE, "Unable to look up host name - falling back to UUID " + hostName, ex); + } + + // Read from module settings to determine if the module is enabled + if (ModuleSettings.settingExists(MODULE_NAME, IS_ENABLED_KEY)) { + if(ModuleSettings.getConfigSetting(MODULE_NAME, IS_ENABLED_KEY).equals("true")){ + isEnabled.set(true); + try { + activateMonitor(); + } catch (HealthMonitorException ex) { + // If we failed to activate it, then disable the monitor + logger.log(Level.SEVERE, "Health monitor activation failed - disabling health monitor"); + setEnabled(false); + throw ex; + } + return; + } + } + isEnabled.set(false); + } + + /** + * Get the instance of the EnterpriseHealthMonitor + * @return the instance + * @throws HealthMonitorException + */ + synchronized static EnterpriseHealthMonitor getInstance() throws HealthMonitorException { + if (instance == null) { + instance = new EnterpriseHealthMonitor(); + Case.addPropertyChangeListener(instance); + } + return instance; + } + + /** + * Activate the health monitor. + * Creates/initialized the database (if needed), clears any existing metrics + * out of the maps, and sets up the timer for writing to the database. + * @throws HealthMonitorException + */ + private synchronized void activateMonitor() throws HealthMonitorException { + + logger.log(Level.INFO, "Activating Servies Health Monitor"); + + if (!UserPreferences.getIsMultiUserModeEnabled()) { + throw new HealthMonitorException("Multi user mode is not enabled - can not activate health monitor"); + } + + // Set up database (if needed) + try (CoordinationService.Lock lock = getExclusiveDbLock()) { + if(lock == null) { + throw new HealthMonitorException("Error getting database lock"); + } + + // Check if the database exists + if (! databaseExists()) { + + // If not, create a new one + createDatabase(); + } + + if( ! databaseIsInitialized()) { + initializeDatabaseSchema(); + } + + } catch (CoordinationService.CoordinationServiceException ex) { + throw new HealthMonitorException("Error releasing database lock", ex); + } + + // Clear out any old data + timingInfoMap.clear(); + + // Start the timer for database writes + startTimer(); + } + + /** + * Deactivate the health monitor. + * This should only be used when disabling the monitor, not when Autopsy is closing. + * Clears out any metrics that haven't been written, stops the database write timer, + * and shuts down the connection pool. + * @throws HealthMonitorException + */ + private synchronized void deactivateMonitor() throws HealthMonitorException { + + logger.log(Level.INFO, "Deactivating Servies Health Monitor"); + + // Clear out the collected data + timingInfoMap.clear(); + + // Stop the timer + stopTimer(); + + // Shut down the connection pool + shutdownConnections(); + } + + /** + * Start the ScheduledThreadPoolExecutor that will handle the database writes. + */ + private synchronized void startTimer() { + // Make sure the previous executor (if it exists) has been stopped + stopTimer(); + + healthMonitorOutputTimer = new ScheduledThreadPoolExecutor(1, new ThreadFactoryBuilder().setNameFormat("health_monitor_timer").build()); + healthMonitorOutputTimer.scheduleWithFixedDelay(new DatabaseWriteTask(), DATABASE_WRITE_INTERVAL, DATABASE_WRITE_INTERVAL, TimeUnit.MINUTES); + } + + /** + * Stop the ScheduledThreadPoolExecutor to prevent further database writes. + */ + private synchronized void stopTimer() { + if(healthMonitorOutputTimer != null) { + ThreadUtils.shutDownTaskExecutor(healthMonitorOutputTimer); + } + } + + /** + * Called from the installer to set up the Health Monitor instance at startup. + * @throws HealthMonitorException + */ + static synchronized void startUpIfEnabled() throws HealthMonitorException { + getInstance(); + } + + /** + * Enabled/disable the health monitor. + * @param enabled true to enable the monitor, false to disable it + * @throws HealthMonitorException + */ + static synchronized void setEnabled(boolean enabled) throws HealthMonitorException { + if(enabled == isEnabled.get()) { + // The setting has not changed, so do nothing + return; + } + + if(enabled) { + getInstance().activateMonitor(); + + // If activateMonitor fails, we won't update either of these + ModuleSettings.setConfigSetting(MODULE_NAME, IS_ENABLED_KEY, "true"); + isEnabled.set(true); + } else { + ModuleSettings.setConfigSetting(MODULE_NAME, IS_ENABLED_KEY, "false"); + isEnabled.set(false); + getInstance().deactivateMonitor(); + } + } + + /** + * Get a metric that will measure the time to execute a section of code. + * Call this before the section of code to be timed and then + * submit it afterward using submitTimingMetric(). + * This method is safe to call regardless of whether the Enterprise Health + * Monitor is enabled. + * @param name A short but descriptive name describing the code being timed. + * This name will appear in the UI. + * @return The TimingMetric object + */ + public static TimingMetric getTimingMetric(String name) { + if(isEnabled.get()) { + return new TimingMetric(name); + } + return null; + } + + /** + * Submit the metric that was previously obtained through getTimingMetric(). + * Call this immediately after the section of code being timed. + * This method is safe to call regardless of whether the Enterprise Health + * Monitor is enabled. + * @param metric The TimingMetric object obtained from getTimingMetric() + */ + public static void submitTimingMetric(TimingMetric metric) { + if(isEnabled.get() && (metric != null)) { + metric.stopTiming(); + try { + getInstance().addTimingMetric(metric); + } catch (HealthMonitorException ex) { + // We don't want calling methods to have to check for exceptions, so just log it + logger.log(Level.SEVERE, "Error adding timing metric", ex); + } + } + } + + /** + * Add the timing metric data to the map. + * @param metric The metric to add. stopTiming() should already have been called. + */ + private void addTimingMetric(TimingMetric metric) throws HealthMonitorException { + + // Do as little as possible within the synchronized block to minimize + // blocking with multiple threads. + synchronized(this) { + // There's a small check-then-act situation here where isEnabled + // may have changed before reaching this code. This is fine - + // the map still exists and any extra data added after the monitor + // is disabled will be deleted if the monitor is re-enabled. This + // seems preferable to doing another check on isEnabled within + // the synchronized block. + if(timingInfoMap.containsKey(metric.getName())) { + timingInfoMap.get(metric.getName()).addMetric(metric); + } else { + timingInfoMap.put(metric.getName(), new TimingInfo(metric)); + } + } + } + + /** + * Write the collected metrics to the database. + * @throws HealthMonitorException + */ + private void writeCurrentStateToDatabase() throws HealthMonitorException { + + Map timingMapCopy; + + // Do as little as possible within the synchronized block since it will + // block threads attempting to record metrics. + synchronized(this) { + if(! isEnabled.get()) { + return; + } + + // Make a shallow copy of the timing map. The map should be small - one entry + // per metric name. + timingMapCopy = new HashMap<>(timingInfoMap); + timingInfoMap.clear(); + } + + // Check if there's anything to report (right now we only have the timing map) + if(timingMapCopy.keySet().isEmpty()) { + return; + } + + logger.log(Level.INFO, "Writing health monitor metrics to database"); + + // Write to the database + try (CoordinationService.Lock lock = getSharedDbLock()) { + if(lock == null) { + throw new HealthMonitorException("Error getting database lock"); + } + + Connection conn = connect(); + if(conn == null) { + throw new HealthMonitorException("Error getting database connection"); + } + + // Add timing metrics to the database + String addTimingInfoSql = "INSERT INTO timing_data (name, host, timestamp, count, average, max, min) VALUES (?, ?, ?, ?, ?, ?, ?)"; + try (PreparedStatement statement = conn.prepareStatement(addTimingInfoSql)) { + + for(String name:timingMapCopy.keySet()) { + TimingInfo info = timingMapCopy.get(name); + + statement.setString(1, name); + statement.setString(2, hostName); + statement.setLong(3, System.currentTimeMillis()); + statement.setLong(4, info.getCount()); + statement.setLong(5, info.getAverage()); + statement.setLong(6, info.getMax()); + statement.setLong(7, info.getMin()); + + statement.execute(); + } + + } catch (SQLException ex) { + throw new HealthMonitorException("Error saving metric data to database", ex); + } finally { + try { + conn.close(); + } catch (SQLException ex) { + logger.log(Level.SEVERE, "Error closing Connection.", ex); + } + } + } catch (CoordinationService.CoordinationServiceException ex) { + throw new HealthMonitorException("Error releasing database lock", ex); + } + } + + /** + * Check whether the health monitor database exists. + * Does not check the schema. + * @return true if the database exists, false otherwise + * @throws HealthMonitorException + */ + private boolean databaseExists() throws HealthMonitorException { + try { + // Use the same database settings as the case + CaseDbConnectionInfo db = UserPreferences.getDatabaseConnectionInfo(); + Class.forName("org.postgresql.Driver"); //NON-NLS + ResultSet rs = null; + try (Connection connection = DriverManager.getConnection("jdbc:postgresql://" + db.getHost() + ":" + db.getPort() + "/postgres", db.getUserName(), db.getPassword()); //NON-NLS + Statement statement = connection.createStatement();) { + String createCommand = "SELECT 1 AS result FROM pg_database WHERE datname='" + DATABASE_NAME + "'"; + rs = statement.executeQuery(createCommand); + if(rs.next()) { + logger.log(Level.INFO, "Existing Enterprise Health Monitor database found"); + return true; + } + } finally { + if(rs != null) { + rs.close(); + } + } + } catch (UserPreferencesException | ClassNotFoundException | SQLException ex) { + throw new HealthMonitorException("Failed check for health monitor database", ex); + } + return false; + } + + /** + * Create a new health monitor database. + * @throws HealthMonitorException + */ + private void createDatabase() throws HealthMonitorException { + try { + // Use the same database settings as the case + CaseDbConnectionInfo db = UserPreferences.getDatabaseConnectionInfo(); + Class.forName("org.postgresql.Driver"); //NON-NLS + try (Connection connection = DriverManager.getConnection("jdbc:postgresql://" + db.getHost() + ":" + db.getPort() + "/postgres", db.getUserName(), db.getPassword()); //NON-NLS + Statement statement = connection.createStatement();) { + String createCommand = "CREATE DATABASE \"" + DATABASE_NAME + "\" OWNER \"" + db.getUserName() + "\""; //NON-NLS + statement.execute(createCommand); + } + logger.log(Level.INFO, "Created new health monitor database " + DATABASE_NAME); + } catch (UserPreferencesException | ClassNotFoundException | SQLException ex) { + throw new HealthMonitorException("Failed to delete health monitor database", ex); + } + } + + /** + * Setup a connection pool for db connections. + * @throws HealthMonitorException + */ + private void setupConnectionPool() throws HealthMonitorException { + try { + CaseDbConnectionInfo db = UserPreferences.getDatabaseConnectionInfo(); + + connectionPool = new BasicDataSource(); + connectionPool.setDriverClassName("org.postgresql.Driver"); + + StringBuilder connectionURL = new StringBuilder(); + connectionURL.append("jdbc:postgresql://"); + connectionURL.append(db.getHost()); + connectionURL.append(":"); + connectionURL.append(db.getPort()); + connectionURL.append("/"); + connectionURL.append(DATABASE_NAME); + + connectionPool.setUrl(connectionURL.toString()); + connectionPool.setUsername(db.getUserName()); + connectionPool.setPassword(db.getPassword()); + + // tweak pool configuration + connectionPool.setInitialSize(3); // start with 3 connections + connectionPool.setMaxIdle(CONN_POOL_SIZE); // max of 10 idle connections + connectionPool.setValidationQuery("SELECT version()"); + } catch (UserPreferencesException ex) { + throw new HealthMonitorException("Error loading database configuration", ex); + } + } + + /** + * Shut down the connection pool + * @throws HealthMonitorException + */ + private void shutdownConnections() throws HealthMonitorException { + try { + synchronized(this) { + if(connectionPool != null){ + connectionPool.close(); + connectionPool = null; // force it to be re-created on next connect() + } + } + } catch (SQLException ex) { + throw new HealthMonitorException("Failed to close existing database connections.", ex); // NON-NLS + } + } + + /** + * Get a database connection. + * Sets up the connection pool if needed. + * @return The Connection object + * @throws HealthMonitorException + */ + private Connection connect() throws HealthMonitorException { + synchronized (this) { + if (connectionPool == null) { + setupConnectionPool(); + } + } + + try { + return connectionPool.getConnection(); + } catch (SQLException ex) { + throw new HealthMonitorException("Error getting connection from connection pool.", ex); // NON-NLS + } + } + + /** + * Test whether the database schema has been initialized. + * We do this by looking for the version number. + * @return True if it has been initialized, false otherwise. + * @throws HealthMonitorException + */ + private boolean databaseIsInitialized() throws HealthMonitorException { + Connection conn = connect(); + if(conn == null) { + throw new HealthMonitorException("Error getting database connection"); + } + ResultSet resultSet = null; + + try (Statement statement = conn.createStatement()) { + resultSet = statement.executeQuery("SELECT value FROM db_info WHERE name='SCHEMA_VERSION'"); + return resultSet.next(); + } catch (SQLException ex) { + // This likely just means that the db_info table does not exist + return false; + } finally { + if(resultSet != null) { + try { + resultSet.close(); + } catch (SQLException ex) { + logger.log(Level.SEVERE, "Error closing result set", ex); + } + } + try { + conn.close(); + } catch (SQLException ex) { + logger.log(Level.SEVERE, "Error closing Connection.", ex); + } + } + } + + /** + * Get the current schema version + * @return the current schema version + * @throws HealthMonitorException + */ + private CaseDbSchemaVersionNumber getVersion() throws HealthMonitorException { + Connection conn = connect(); + if(conn == null) { + throw new HealthMonitorException("Error getting database connection"); + } + ResultSet resultSet = null; + + try (Statement statement = conn.createStatement()) { + int minorVersion = 0; + int majorVersion = 0; + resultSet = statement.executeQuery("SELECT value FROM db_info WHERE name='SCHEMA_MINOR_VERSION'"); + if (resultSet.next()) { + String minorVersionStr = resultSet.getString("value"); + try { + minorVersion = Integer.parseInt(minorVersionStr); + } catch (NumberFormatException ex) { + throw new HealthMonitorException("Bad value for schema minor version (" + minorVersionStr + ") - database is corrupt"); + } + } + + resultSet = statement.executeQuery("SELECT value FROM db_info WHERE name='SCHEMA_VERSION'"); + if (resultSet.next()) { + String majorVersionStr = resultSet.getString("value"); + try { + majorVersion = Integer.parseInt(majorVersionStr); + } catch (NumberFormatException ex) { + throw new HealthMonitorException("Bad value for schema version (" + majorVersionStr + ") - database is corrupt"); + } + } + + return new CaseDbSchemaVersionNumber(majorVersion, minorVersion); + } catch (SQLException ex) { + throw new HealthMonitorException("Error initializing database", ex); + } finally { + if(resultSet != null) { + try { + resultSet.close(); + } catch (SQLException ex) { + logger.log(Level.SEVERE, "Error closing result set", ex); + } + } + try { + conn.close(); + } catch (SQLException ex) { + logger.log(Level.SEVERE, "Error closing Connection.", ex); + } + } + } + + /** + * Initialize the database. + * @throws HealthMonitorException + */ + private void initializeDatabaseSchema() throws HealthMonitorException { + Connection conn = connect(); + if(conn == null) { + throw new HealthMonitorException("Error getting database connection"); + } + + try (Statement statement = conn.createStatement()) { + conn.setAutoCommit(false); + + String createTimingTable = + "CREATE TABLE IF NOT EXISTS timing_data (" + + "id SERIAL PRIMARY KEY," + + "name text NOT NULL," + + "host text NOT NULL," + + "timestamp bigint NOT NULL," + + "count bigint NOT NULL," + + "average bigint NOT NULL," + + "max bigint NOT NULL," + + "min bigint NOT NULL" + + ")"; + statement.execute(createTimingTable); + + String createDbInfoTable = + "CREATE TABLE IF NOT EXISTS db_info (" + + "id SERIAL PRIMARY KEY NOT NULL," + + "name text NOT NULL," + + "value text NOT NULL" + + ")"; + statement.execute(createDbInfoTable); + + statement.execute("INSERT INTO db_info (name, value) VALUES ('SCHEMA_VERSION', '" + CURRENT_DB_SCHEMA_VERSION.getMajor() + "')"); + statement.execute("INSERT INTO db_info (name, value) VALUES ('SCHEMA_MINOR_VERSION', '" + CURRENT_DB_SCHEMA_VERSION.getMinor() + "')"); + + conn.commit(); + } catch (SQLException ex) { + try { + conn.rollback(); + } catch (SQLException ex2) { + logger.log(Level.SEVERE, "Rollback error"); + } + throw new HealthMonitorException("Error initializing database", ex); + } finally { + try { + conn.close(); + } catch (SQLException ex) { + logger.log(Level.SEVERE, "Error closing connection.", ex); + } + } + } + + /** + * The task called by the ScheduledThreadPoolExecutor to handle + * the database writes. + */ + static final class DatabaseWriteTask implements Runnable { + + /** + * Write current metric data to the database + */ + @Override + public void run() { + try { + getInstance().writeCurrentStateToDatabase(); + } catch (HealthMonitorException ex) { + logger.log(Level.SEVERE, "Error writing current metrics to database", ex); //NON-NLS + } + } + } + + @Override + public void propertyChange(PropertyChangeEvent evt) { + + switch (Case.Events.valueOf(evt.getPropertyName())) { + + case CURRENT_CASE: + if ((null == evt.getNewValue()) && (evt.getOldValue() instanceof Case)) { + // When a case is closed, write the current metrics to the database + healthMonitorExecutor.submit(new EnterpriseHealthMonitor.DatabaseWriteTask()); + } + break; + } + } + + /** + * Get an exclusive lock for the health monitor database. + * Acquire this before creating, initializing, or updating the database schema. + * @return The lock + * @throws HealthMonitorException + */ + private CoordinationService.Lock getExclusiveDbLock() throws HealthMonitorException{ + try { + CoordinationService.Lock lock = CoordinationService.getInstance().tryGetExclusiveLock(CoordinationService.CategoryNode.HEALTH_MONITOR, DATABASE_NAME, 5, TimeUnit.MINUTES); + + if(lock != null){ + return lock; + } + throw new HealthMonitorException("Error acquiring database lock"); + } catch (InterruptedException | CoordinationService.CoordinationServiceException ex){ + throw new HealthMonitorException("Error acquiring database lock", ex); + } + } + + /** + * Get an shared lock for the health monitor database. + * Acquire this before database reads or writes. + * @return The lock + * @throws HealthMonitorException + */ + private CoordinationService.Lock getSharedDbLock() throws HealthMonitorException{ + try { + String databaseNodeName = DATABASE_NAME; + CoordinationService.Lock lock = CoordinationService.getInstance().tryGetSharedLock(CoordinationService.CategoryNode.HEALTH_MONITOR, databaseNodeName, 5, TimeUnit.MINUTES); + + if(lock != null){ + return lock; + } + throw new HealthMonitorException("Error acquiring database lock"); + } catch (InterruptedException | CoordinationService.CoordinationServiceException ex){ + throw new HealthMonitorException("Error acquiring database lock"); + } + } + + /** + * Internal class for collecting timing metrics. + * Instead of storing each TimingMetric, we only store the min and max + * seen and the number of metrics and total duration to compute the average + * later. + * One TimingInfo instance should be created per metric name, and + * additional timing metrics will be added to it. + */ + private class TimingInfo { + private long count; // Number of metrics collected + private long sum; // Sum of the durations collected (nanoseconds) + private long max; // Maximum value found (nanoseconds) + private long min; // Minimum value found (nanoseconds) + + TimingInfo(TimingMetric metric) throws HealthMonitorException { + count = 1; + sum = metric.getDuration(); + max = metric.getDuration(); + min = metric.getDuration(); + } + + /** + * Add a new TimingMetric to an existing TimingInfo object. + * This is called in a synchronized block for almost all new + * TimingMetric objects, so do as little processing here as possible. + * @param metric The new metric + * @throws HealthMonitorException Will be thrown if the metric hasn't been stopped + */ + void addMetric(TimingMetric metric) throws HealthMonitorException { + + // Keep track of needed info to calculate the average + count++; + sum += metric.getDuration(); + + // Check if this is the longest duration seen + if(max < metric.getDuration()) { + max = metric.getDuration(); + } + + // Check if this is the lowest duration seen + if(min > metric.getDuration()) { + min = metric.getDuration(); + } + } + + /** + * Get the average duration + * @return average duration (nanoseconds) + */ + long getAverage() { + return sum / count; + } + + /** + * Get the maximum duration + * @return maximum duration (nanoseconds) + */ + long getMax() { + return max; + } + + /** + * Get the minimum duration + * @return minimum duration (nanoseconds) + */ + long getMin() { + return min; + } + + /** + * Get the total number of metrics collected + * @return number of metrics collected + */ + long getCount() { + return count; + } + } +} diff --git a/Core/src/org/sleuthkit/autopsy/healthmonitor/HealthMonitorException.java b/Core/src/org/sleuthkit/autopsy/healthmonitor/HealthMonitorException.java new file mode 100644 index 0000000000..6df918acaa --- /dev/null +++ b/Core/src/org/sleuthkit/autopsy/healthmonitor/HealthMonitorException.java @@ -0,0 +1,34 @@ +/* + * Autopsy Forensic Browser + * + * Copyright 2018 Basis Technology Corp. + * Contact: carrier sleuthkit org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.sleuthkit.autopsy.healthmonitor; + +/** + * Exception used internally by the Services Health Monitor + */ +class HealthMonitorException extends Exception { + private static final long serialVersionUID = 1L; + + HealthMonitorException(String message) { + super(message); + } + + HealthMonitorException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/Core/src/org/sleuthkit/autopsy/healthmonitor/Installer.java b/Core/src/org/sleuthkit/autopsy/healthmonitor/Installer.java new file mode 100644 index 0000000000..61ea5a5244 --- /dev/null +++ b/Core/src/org/sleuthkit/autopsy/healthmonitor/Installer.java @@ -0,0 +1,53 @@ +/* + * Autopsy Forensic Browser + * + * Copyright 2018 Basis Technology Corp. + * Contact: carrier sleuthkit org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.sleuthkit.autopsy.healthmonitor; + +import java.util.logging.Level; +import org.openide.modules.ModuleInstall; +import org.sleuthkit.autopsy.casemodule.Case; +import org.sleuthkit.autopsy.coreutils.Logger; + +public class Installer extends ModuleInstall { + + private static final Logger logger = Logger.getLogger(Installer.class.getName()); + private static final long serialVersionUID = 1L; + + private static Installer instance; + + public synchronized static Installer getDefault() { + if (instance == null) { + instance = new Installer(); + } + return instance; + } + + private Installer() { + super(); + } + + @Override + public void restored() { + + try { + EnterpriseHealthMonitor.startUpIfEnabled(); + } catch (HealthMonitorException ex) { + logger.log(Level.SEVERE, "Error starting health services monitor", ex); + } + } +} \ No newline at end of file diff --git a/Core/src/org/sleuthkit/autopsy/healthmonitor/TimingMetric.java b/Core/src/org/sleuthkit/autopsy/healthmonitor/TimingMetric.java new file mode 100644 index 0000000000..d94dfd7161 --- /dev/null +++ b/Core/src/org/sleuthkit/autopsy/healthmonitor/TimingMetric.java @@ -0,0 +1,65 @@ +/* + * Autopsy Forensic Browser + * + * Copyright 2018 Basis Technology Corp. + * Contact: carrier sleuthkit org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.sleuthkit.autopsy.healthmonitor; + +/** + * Used to calculate and report timing metrics. + */ +public class TimingMetric { + + private final String name; + private final long startingTimestamp; + private Long duration; + + TimingMetric(String name) { + this.name = name; + this.startingTimestamp = System.nanoTime(); + this.duration = null; + } + + /** + * Record how long the metric was running. + */ + void stopTiming() { + long endingTimestamp = System.nanoTime(); + this.duration = endingTimestamp - startingTimestamp; + } + + /** + * Get the name of metric + * @return name + */ + String getName() { + return name; + } + + /** + * Get the duration of the metric. Will throw an exception if the + * metric has not been stopped. + * @return how long the metric was running (nanoseconds) + * @throws HealthMonitorException + */ + long getDuration() throws HealthMonitorException { + if (duration != null) { + return duration; + } else { + throw new HealthMonitorException("getDuration() called before stopTiming()"); + } + } +} diff --git a/KeywordSearch/src/org/sleuthkit/autopsy/keywordsearch/Ingester.java b/KeywordSearch/src/org/sleuthkit/autopsy/keywordsearch/Ingester.java index c7f4c07f6a..b9c4541c7b 100644 --- a/KeywordSearch/src/org/sleuthkit/autopsy/keywordsearch/Ingester.java +++ b/KeywordSearch/src/org/sleuthkit/autopsy/keywordsearch/Ingester.java @@ -27,6 +27,8 @@ import org.apache.solr.common.SolrInputDocument; import org.openide.util.NbBundle; import org.sleuthkit.autopsy.coreutils.Logger; import org.sleuthkit.autopsy.datamodel.ContentUtils; +import org.sleuthkit.autopsy.healthmonitor.EnterpriseHealthMonitor; +import org.sleuthkit.autopsy.healthmonitor.TimingMetric; import org.sleuthkit.autopsy.ingest.IngestJobContext; import org.sleuthkit.autopsy.keywordsearch.Chunker.Chunk; import org.sleuthkit.datamodel.AbstractFile; @@ -235,7 +237,9 @@ class Ingester { try { //TODO: consider timeout thread, or vary socket timeout based on size of indexed content + TimingMetric metric = EnterpriseHealthMonitor.getTimingMetric("Solr: Index chunk"); solrServer.addDocument(updateDoc); + EnterpriseHealthMonitor.submitTimingMetric(metric); uncommitedIngests = true; } catch (KeywordSearchModuleException | NoOpenCoreException ex) { diff --git a/KeywordSearch/src/org/sleuthkit/autopsy/keywordsearch/Server.java b/KeywordSearch/src/org/sleuthkit/autopsy/keywordsearch/Server.java index 416f2b394f..eea4d2914e 100644 --- a/KeywordSearch/src/org/sleuthkit/autopsy/keywordsearch/Server.java +++ b/KeywordSearch/src/org/sleuthkit/autopsy/keywordsearch/Server.java @@ -70,6 +70,8 @@ import org.sleuthkit.autopsy.core.UserPreferences; import org.sleuthkit.autopsy.coreutils.Logger; import org.sleuthkit.autopsy.coreutils.ModuleSettings; import org.sleuthkit.autopsy.coreutils.PlatformUtil; +import org.sleuthkit.autopsy.healthmonitor.EnterpriseHealthMonitor; +import org.sleuthkit.autopsy.healthmonitor.TimingMetric; import org.sleuthkit.autopsy.keywordsearchservice.KeywordSearchServiceException; import org.sleuthkit.datamodel.Content; @@ -773,7 +775,9 @@ public class Server { IndexingServerProperties properties = getMultiUserServerProperties(theCase.getCaseDirectory()); currentSolrServer = new HttpSolrServer("http://" + properties.getHost() + ":" + properties.getPort() + "/solr"); //NON-NLS } + TimingMetric metric = EnterpriseHealthMonitor.getTimingMetric("Solr: Connectivity check"); connectToSolrServer(currentSolrServer); + EnterpriseHealthMonitor.submitTimingMetric(metric); } catch (SolrServerException | IOException ex) { throw new KeywordSearchModuleException(NbBundle.getMessage(Server.class, "Server.connect.exception.msg", ex.getLocalizedMessage()), ex);