Appian Usage Insights

Overview

Gain insights into your Appian usage with an application suite specifically designed to cater for complex environment topologies and heterogenous license contracts.

Key Features & Functionality

With ‘Appian Usage Insights’:

  • Model your license contracts in the application through configuration only - no additional development required!
  • Cater for Cloud, self-managed or a mix of installation topologies.
  • Designed with the enterprise in mind, support large numbers of Appian environments, user accounts and keep track of license consumption over time.
  • Model your internal license usage using license pools mapped to user-definable business entities.
  • Visualize usage the way you want to:
    • Usage by business entity.
    • Usage by license type.
    • License pool membership.
    • License pool logins.
    • License pool membership (which user is in which license pool and, ultimately, which one consumes an actual license).
    • Logins by user (over time).
    • Capacity planning (allocated license count over time).

For more information, see https://community.appian.com/w/the-appian-playbook/2198/appian-usage-insights-faqs

Usage of this application does not permit your organization to exceed its licensed usage requirements. Your organization is required to maintain compliance with your licensing terms, and report any non-compliance to Appian per the terms of your organization’s contract with Appian.

Anonymous
Parents
  • Hi David,

    Thank you for building this app/plugin.

    We're using your smart service Collect Audited Logins to retrieve last login timestamp information for our Access Management app in Appian. 

    However, our Appian environments are quite heavy in terms of API calls - we're receiving thousands per day. Due to this, the generated CSV file often reaches >100MB just because of the recorded login attempts of Service Accounts. 

    We only intend to retrieve logins of actual business users which can be done by excluding service accounts in the collected logins.

    We've created a forked version of your plugin to do this:

    1. Added smart service parameter for the excluded groups (e.g., Service Accounts group as input)

    2. Added getExcludeUserList which fetches the members of the excluded groups provided

    3. Updated CollectAuditedLogins.processLogins function to filter entries by username

    I've attached the modified Java class for reference.

    Do you think it's possible to (officially) include said changes to your plugin?

    Thanks!

    package com.unionbankph.plugins.licman.process.activities;
    
    import java.io.BufferedReader;
    import java.io.File;
    import java.io.FileFilter;
    import java.io.FileInputStream;
    import java.io.IOException;
    import java.io.InputStreamReader;
    import java.sql.Date;
    import java.text.SimpleDateFormat;
    import java.util.ArrayList;
    import java.util.Arrays;
    import java.util.List;
    
    import org.apache.log4j.Logger;
    
    import com.appiancorp.suiteapi.cfg.ConfigurationLoader;
    import com.appiancorp.suiteapi.common.Name;
    import com.appiancorp.suiteapi.common.exceptions.InvalidGroupException;
    import com.appiancorp.suiteapi.common.exceptions.InvalidVersionException;
    import com.appiancorp.suiteapi.common.exceptions.PrivilegeException;
    import com.appiancorp.suiteapi.content.ContentService;
    import com.appiancorp.suiteapi.content.exceptions.InvalidContentException;
    import com.appiancorp.suiteapi.personalization.GroupDataType;
    import com.appiancorp.suiteapi.personalization.GroupService;
    import com.appiancorp.suiteapi.process.exceptions.SmartServiceException;
    import com.appiancorp.suiteapi.process.framework.Input;
    import com.appiancorp.suiteapi.process.framework.MessageContainer;
    import com.appiancorp.suiteapi.process.framework.Required;
    import com.appiancorp.suiteapi.process.palette.PaletteCategoryConstants;
    import com.appiancorp.suiteapi.process.palette.PaletteInfo;
    import com.unionbankph.plugins.licman.LicManUtils;
    import com.unionbankph.plugins.licman.LoginRecord;
    
    @PaletteInfo(paletteCategory = PaletteCategoryConstants.APPIAN_SMART_SERVICES, palette = "Analytics")
    public class CollectAuditedLogins extends AbstractCollect {
      private static final Logger LOG = Logger.getLogger(CollectAuditedLogins.class);
    
      private static final String AUDIT_LOG_FILENAME = "login-audit";
      private static final String[] AUDIT_LOG_FILENAME_BAD_MATCH = { "tar", "gz", "zip", "login-audit-uuid" };
      private static final String SHARED_LOGS_FOLDERNAME = "shared-logs";
      private static final String AUDIT_LOGIN_SUCCESS = "Succeeded";
      private static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
    
      // Additional Inputs
      private Date startDate = null;
      private Date endDate = null;
      private Long[] excludeGroupIds;
    
      public CollectAuditedLogins(ContentService cs, GroupService gs) {
        super(cs, gs);
      }
    
      @Override
      public void runSmartService(File tempFile)
        throws InvalidContentException, IOException, InvalidVersionException, PrivilegeException, SmartServiceException {
        try {
    
          List<File> allTargetLogFiles = new ArrayList<>();
    
          allTargetLogFiles = getTargetLogFiles();
    
          for (File file : allTargetLogFiles) {
            processLogins(file.getAbsolutePath(), tempFile);
          }
    
          LOG.debug(String.format("All succesful logins since %s retrieved and written to temp file.", dateFormat.format(startDate)));
    
        } catch (Exception e) {
          LOG.debug("Error retrieving or processing login audit logs.", e);
          throw new SmartServiceException.Builder(getClass(), e).build();
        }
      }
    
      // Additional validations
      public void validate(MessageContainer messages) {
        super.validate(messages);
        if (startDate == null) {
          messages.addError("StartDate", "startdate.missing");
        }
        if (endDate == null) {
          messages.addError("EndDate", "enddate.missing");
        }
      }
    
      private List<File> getTargetLogFiles() {
        String aeLogsPath = ConfigurationLoader.getConfiguration().getAeLogs();
        String sharedLogsPath = getSharedLogsPath(aeLogsPath);
    
        if (sharedLogsPath != null) {
          // We have shared logs
          LOG.debug(String.format("shared-logs directory found at %s", sharedLogsPath));
    
          List<File> allSharedLogDirs = getSubDirs(sharedLogsPath);
          List<File> allSharedLoginAuditFiles = new ArrayList<>();
          for (File logDir : allSharedLogDirs) {
            LOG.debug(String.format("Extracting logs from node path at %s", logDir.getAbsolutePath()));
            List<File> matchingFiles = getMatchingFilesFromDirectory(logDir.getAbsolutePath());
            for (File match : matchingFiles) {
              allSharedLoginAuditFiles.add(match);
            }
          }
    
          return allSharedLoginAuditFiles;
        } else {
          LOG.debug(String.format("No shared-logs path exists. Defaulting to aeLogsPath at %s", aeLogsPath));
          return getMatchingFilesFromDirectory(aeLogsPath);
        }
      }
    
      private static String getSharedLogsPath(String aeLogsPath) {
        /*
         * gettAeLogs - this is either:
         * -AE_HOME/shared-logs/<node> -> so look 1 up for shared-logs
         * -AE_HOME/logs -> so look at sibling for shared-logs
         * -> also check getAeLogs for shared-logs just in case
         */
        String logParentPath = new File(aeLogsPath).getParent();
        String testName = new File(logParentPath).getName();
        String testPath = logParentPath + File.separator + SHARED_LOGS_FOLDERNAME;
    
        // Check aeLogs parent (testName is parent dir name)
        if (testName.equals(SHARED_LOGS_FOLDERNAME)) {
          return logParentPath;
        }
        // Check aeLogs sibling (testPath is would be sibling path)
        else if (new File(testPath).exists()) {
          return testPath;
        }
        // Check if this is shared logs path...
        else if (new File(aeLogsPath).getName().equals(SHARED_LOGS_FOLDERNAME)) {
          return aeLogsPath;
        }
        // Else no shared-logs
        return null;
      }
    
      private List<File> getMatchingFilesFromDirectory(String directoryName) {
        File[] matchingFiles = new File(directoryName).listFiles(new FileFilter() {
          @Override
          public boolean accept(File file) {
            return file.isFile() && isValidFileName(file.getAbsolutePath());
          }
        });
    
        return Arrays.asList(matchingFiles);
      }
    
      private static List<File> getSubDirs(String directoryName) {
        File[] subDirs = new File(directoryName).listFiles(new FileFilter() {
          @Override
          public boolean accept(File file) {
            return file.isDirectory();
          }
        });
    
        return Arrays.asList(subDirs);
      }
    
      private boolean isValidFileName(String filename) {
        // Match on file name
        if (filename.contains(AUDIT_LOG_FILENAME)) {
          // Reject bad matches
          for (String s : AUDIT_LOG_FILENAME_BAD_MATCH) {
            if (filename.contains(s)) {
              return false;
            }
          }
          // Filter by date range
          Date dateFromFilename = getDateFromFilename(filename);
          if (dateFromFilename == null) {
            return false;
          }
          if (!dateFromFilename.before(startDate) && !dateFromFilename.after(endDate)) {
            return true;
          }
        }
        return false;
      }
    
      private static Date getDateFromFilename(String filename) {
        String suffix = filename.substring(filename.lastIndexOf(".") + 1);
        if (suffix.length() == 10) {
          // Valid length for a date
          try {
            Date extractedDate = Date.valueOf(suffix);
            return extractedDate;
          } catch (Exception e) {
            LOG.debug(String.format("Failed to parse filename extracted date of %s to a date. Skipping file. Error was %s", suffix, e));
            return null;
          }
        } else {
          return null;
        }
      };
    
      private void processLogins(String filename, File tempFile) throws Exception {
        // Extract logins from a single file and push to temp file CSV
        List<LoginRecord> loginRecordList = new ArrayList<LoginRecord>();
        List<String> excludeUsernames = getExcludeUserList();
    
        try (BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(filename)))) {
    
          String strLine = null;
          while ((strLine = br.readLine()) != null) {
            try {
              LoginRecord lr = new LoginRecord();
              lr.parseLoginAuditLine(strLine);
              // Successful audit (target date range has already been validated at file level)
              // Username not included in excluded groups
              if (lr.getResult().equals(AUDIT_LOGIN_SUCCESS) 
                  && (excludeUsernames.isEmpty() || !excludeUsernames.contains(lr.getUsername()))) {
                loginRecordList.add(lr);
              }
            } catch (Exception e) {
              LOG.debug("Failed to read audit log line. Skipping line. Line was: " + strLine, e);
            }
          }
        } catch (Exception e) {
          LOG.debug("Failed to read from entries from audit log.", e);
          throw e;
        }
    
        if (loginRecordList != null) {
          appendRecordsToCSV(tempFile, loginRecordList);
        }
      }
    
      
      private List<String> getExcludeUserList () throws InvalidGroupException {
        List<String> usernames = new ArrayList<String>();
    
        if (this.excludeGroupIds == null) 
          return usernames;
    
        for (Long groupId: this.excludeGroupIds) {
          usernames.addAll(Arrays.asList(this.gs.getMemberUsernames(groupId)));
        }
        return usernames;
      }
    
      private void appendRecordsToCSV(File file, List<LoginRecord> lrList) throws Exception {
        // TODO - Post-MVP - replace the code in with an implementation using bean to CSV writing to write full List<T> to CSV in one go
        List<String[]> records = new ArrayList<>();
    
        for (LoginRecord lr : lrList) {
          records.add(lr.getStringArray());
        }
    
        LicManUtils.appendStringArrayToCSV(file, records);
      }
    
      @Input(required = Required.ALWAYS)
      @Name("StartDate")
      public void setStartDate(Date startDate) {
        this.startDate = startDate;
      }
    
      @Input(required = Required.ALWAYS)
      @Name("EndDate")
      public void setEndDate(Date endDate) {
        this.endDate = endDate;
      }
    
      @Input(required = Required.OPTIONAL)
      @Name("ExcludeGroups")
      @GroupDataType
      public void setExcludeGroups(Long[] val) {
        this.excludeGroupIds = val;
      }
    }

Comment
  • Hi David,

    Thank you for building this app/plugin.

    We're using your smart service Collect Audited Logins to retrieve last login timestamp information for our Access Management app in Appian. 

    However, our Appian environments are quite heavy in terms of API calls - we're receiving thousands per day. Due to this, the generated CSV file often reaches >100MB just because of the recorded login attempts of Service Accounts. 

    We only intend to retrieve logins of actual business users which can be done by excluding service accounts in the collected logins.

    We've created a forked version of your plugin to do this:

    1. Added smart service parameter for the excluded groups (e.g., Service Accounts group as input)

    2. Added getExcludeUserList which fetches the members of the excluded groups provided

    3. Updated CollectAuditedLogins.processLogins function to filter entries by username

    I've attached the modified Java class for reference.

    Do you think it's possible to (officially) include said changes to your plugin?

    Thanks!

    package com.unionbankph.plugins.licman.process.activities;
    
    import java.io.BufferedReader;
    import java.io.File;
    import java.io.FileFilter;
    import java.io.FileInputStream;
    import java.io.IOException;
    import java.io.InputStreamReader;
    import java.sql.Date;
    import java.text.SimpleDateFormat;
    import java.util.ArrayList;
    import java.util.Arrays;
    import java.util.List;
    
    import org.apache.log4j.Logger;
    
    import com.appiancorp.suiteapi.cfg.ConfigurationLoader;
    import com.appiancorp.suiteapi.common.Name;
    import com.appiancorp.suiteapi.common.exceptions.InvalidGroupException;
    import com.appiancorp.suiteapi.common.exceptions.InvalidVersionException;
    import com.appiancorp.suiteapi.common.exceptions.PrivilegeException;
    import com.appiancorp.suiteapi.content.ContentService;
    import com.appiancorp.suiteapi.content.exceptions.InvalidContentException;
    import com.appiancorp.suiteapi.personalization.GroupDataType;
    import com.appiancorp.suiteapi.personalization.GroupService;
    import com.appiancorp.suiteapi.process.exceptions.SmartServiceException;
    import com.appiancorp.suiteapi.process.framework.Input;
    import com.appiancorp.suiteapi.process.framework.MessageContainer;
    import com.appiancorp.suiteapi.process.framework.Required;
    import com.appiancorp.suiteapi.process.palette.PaletteCategoryConstants;
    import com.appiancorp.suiteapi.process.palette.PaletteInfo;
    import com.unionbankph.plugins.licman.LicManUtils;
    import com.unionbankph.plugins.licman.LoginRecord;
    
    @PaletteInfo(paletteCategory = PaletteCategoryConstants.APPIAN_SMART_SERVICES, palette = "Analytics")
    public class CollectAuditedLogins extends AbstractCollect {
      private static final Logger LOG = Logger.getLogger(CollectAuditedLogins.class);
    
      private static final String AUDIT_LOG_FILENAME = "login-audit";
      private static final String[] AUDIT_LOG_FILENAME_BAD_MATCH = { "tar", "gz", "zip", "login-audit-uuid" };
      private static final String SHARED_LOGS_FOLDERNAME = "shared-logs";
      private static final String AUDIT_LOGIN_SUCCESS = "Succeeded";
      private static final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
    
      // Additional Inputs
      private Date startDate = null;
      private Date endDate = null;
      private Long[] excludeGroupIds;
    
      public CollectAuditedLogins(ContentService cs, GroupService gs) {
        super(cs, gs);
      }
    
      @Override
      public void runSmartService(File tempFile)
        throws InvalidContentException, IOException, InvalidVersionException, PrivilegeException, SmartServiceException {
        try {
    
          List<File> allTargetLogFiles = new ArrayList<>();
    
          allTargetLogFiles = getTargetLogFiles();
    
          for (File file : allTargetLogFiles) {
            processLogins(file.getAbsolutePath(), tempFile);
          }
    
          LOG.debug(String.format("All succesful logins since %s retrieved and written to temp file.", dateFormat.format(startDate)));
    
        } catch (Exception e) {
          LOG.debug("Error retrieving or processing login audit logs.", e);
          throw new SmartServiceException.Builder(getClass(), e).build();
        }
      }
    
      // Additional validations
      public void validate(MessageContainer messages) {
        super.validate(messages);
        if (startDate == null) {
          messages.addError("StartDate", "startdate.missing");
        }
        if (endDate == null) {
          messages.addError("EndDate", "enddate.missing");
        }
      }
    
      private List<File> getTargetLogFiles() {
        String aeLogsPath = ConfigurationLoader.getConfiguration().getAeLogs();
        String sharedLogsPath = getSharedLogsPath(aeLogsPath);
    
        if (sharedLogsPath != null) {
          // We have shared logs
          LOG.debug(String.format("shared-logs directory found at %s", sharedLogsPath));
    
          List<File> allSharedLogDirs = getSubDirs(sharedLogsPath);
          List<File> allSharedLoginAuditFiles = new ArrayList<>();
          for (File logDir : allSharedLogDirs) {
            LOG.debug(String.format("Extracting logs from node path at %s", logDir.getAbsolutePath()));
            List<File> matchingFiles = getMatchingFilesFromDirectory(logDir.getAbsolutePath());
            for (File match : matchingFiles) {
              allSharedLoginAuditFiles.add(match);
            }
          }
    
          return allSharedLoginAuditFiles;
        } else {
          LOG.debug(String.format("No shared-logs path exists. Defaulting to aeLogsPath at %s", aeLogsPath));
          return getMatchingFilesFromDirectory(aeLogsPath);
        }
      }
    
      private static String getSharedLogsPath(String aeLogsPath) {
        /*
         * gettAeLogs - this is either:
         * -AE_HOME/shared-logs/<node> -> so look 1 up for shared-logs
         * -AE_HOME/logs -> so look at sibling for shared-logs
         * -> also check getAeLogs for shared-logs just in case
         */
        String logParentPath = new File(aeLogsPath).getParent();
        String testName = new File(logParentPath).getName();
        String testPath = logParentPath + File.separator + SHARED_LOGS_FOLDERNAME;
    
        // Check aeLogs parent (testName is parent dir name)
        if (testName.equals(SHARED_LOGS_FOLDERNAME)) {
          return logParentPath;
        }
        // Check aeLogs sibling (testPath is would be sibling path)
        else if (new File(testPath).exists()) {
          return testPath;
        }
        // Check if this is shared logs path...
        else if (new File(aeLogsPath).getName().equals(SHARED_LOGS_FOLDERNAME)) {
          return aeLogsPath;
        }
        // Else no shared-logs
        return null;
      }
    
      private List<File> getMatchingFilesFromDirectory(String directoryName) {
        File[] matchingFiles = new File(directoryName).listFiles(new FileFilter() {
          @Override
          public boolean accept(File file) {
            return file.isFile() && isValidFileName(file.getAbsolutePath());
          }
        });
    
        return Arrays.asList(matchingFiles);
      }
    
      private static List<File> getSubDirs(String directoryName) {
        File[] subDirs = new File(directoryName).listFiles(new FileFilter() {
          @Override
          public boolean accept(File file) {
            return file.isDirectory();
          }
        });
    
        return Arrays.asList(subDirs);
      }
    
      private boolean isValidFileName(String filename) {
        // Match on file name
        if (filename.contains(AUDIT_LOG_FILENAME)) {
          // Reject bad matches
          for (String s : AUDIT_LOG_FILENAME_BAD_MATCH) {
            if (filename.contains(s)) {
              return false;
            }
          }
          // Filter by date range
          Date dateFromFilename = getDateFromFilename(filename);
          if (dateFromFilename == null) {
            return false;
          }
          if (!dateFromFilename.before(startDate) && !dateFromFilename.after(endDate)) {
            return true;
          }
        }
        return false;
      }
    
      private static Date getDateFromFilename(String filename) {
        String suffix = filename.substring(filename.lastIndexOf(".") + 1);
        if (suffix.length() == 10) {
          // Valid length for a date
          try {
            Date extractedDate = Date.valueOf(suffix);
            return extractedDate;
          } catch (Exception e) {
            LOG.debug(String.format("Failed to parse filename extracted date of %s to a date. Skipping file. Error was %s", suffix, e));
            return null;
          }
        } else {
          return null;
        }
      };
    
      private void processLogins(String filename, File tempFile) throws Exception {
        // Extract logins from a single file and push to temp file CSV
        List<LoginRecord> loginRecordList = new ArrayList<LoginRecord>();
        List<String> excludeUsernames = getExcludeUserList();
    
        try (BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(filename)))) {
    
          String strLine = null;
          while ((strLine = br.readLine()) != null) {
            try {
              LoginRecord lr = new LoginRecord();
              lr.parseLoginAuditLine(strLine);
              // Successful audit (target date range has already been validated at file level)
              // Username not included in excluded groups
              if (lr.getResult().equals(AUDIT_LOGIN_SUCCESS) 
                  && (excludeUsernames.isEmpty() || !excludeUsernames.contains(lr.getUsername()))) {
                loginRecordList.add(lr);
              }
            } catch (Exception e) {
              LOG.debug("Failed to read audit log line. Skipping line. Line was: " + strLine, e);
            }
          }
        } catch (Exception e) {
          LOG.debug("Failed to read from entries from audit log.", e);
          throw e;
        }
    
        if (loginRecordList != null) {
          appendRecordsToCSV(tempFile, loginRecordList);
        }
      }
    
      
      private List<String> getExcludeUserList () throws InvalidGroupException {
        List<String> usernames = new ArrayList<String>();
    
        if (this.excludeGroupIds == null) 
          return usernames;
    
        for (Long groupId: this.excludeGroupIds) {
          usernames.addAll(Arrays.asList(this.gs.getMemberUsernames(groupId)));
        }
        return usernames;
      }
    
      private void appendRecordsToCSV(File file, List<LoginRecord> lrList) throws Exception {
        // TODO - Post-MVP - replace the code in with an implementation using bean to CSV writing to write full List<T> to CSV in one go
        List<String[]> records = new ArrayList<>();
    
        for (LoginRecord lr : lrList) {
          records.add(lr.getStringArray());
        }
    
        LicManUtils.appendStringArrayToCSV(file, records);
      }
    
      @Input(required = Required.ALWAYS)
      @Name("StartDate")
      public void setStartDate(Date startDate) {
        this.startDate = startDate;
      }
    
      @Input(required = Required.ALWAYS)
      @Name("EndDate")
      public void setEndDate(Date endDate) {
        this.endDate = endDate;
      }
    
      @Input(required = Required.OPTIONAL)
      @Name("ExcludeGroups")
      @GroupDataType
      public void setExcludeGroups(Long[] val) {
        this.excludeGroupIds = val;
      }
    }

Children
  • Hi,

    It's a good suggestion to be able to optionally exclude service account logins. I'm glad you've found a solution to this that works for you (and thanks for sharing the details of your solution). There are no current plans to release a new version of this plugin, but I've added this feature to the backlog as I think if we were to release a new version this would be a good addition.

    Regards,

    David