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
  • The information section says it's compatible with 24.3 but I'm not able to import the Appian License Reporting app without upgrading to 25.1

  • v2.0.0 Release Notes
    • Support for Oracle DB scripts (ORA_Application_License_Reporting.sql, ORA_ALRDC_Data_Collector.sql).
  • v2.0.0 Release Notes
    • Plugin:
    •  Bug fix related to collecting group membership to retrieve users starting from index 0.
    • Collector app:
    • Changed name to Appian License Reporting Data Collector (ALRDC)
    • Moved most data types to Synced Records from CDTs to align with Appian's modern design patterns.
    • Process models that required sync nodes were also updated to sync data with stored procedure calls.
    • As such, constants connected to Data Store Entities have been removed.
    • Reporting app:
    • Changed name to Appian License Reporting (ALR)
    • Moved most data types to Synced Records from CDTs to align with Appian's modern design patterns.
    • Process models that required sync nodes were also updated to sync data with stored procedure calls.
    • As such, numerous constants connected to Data Store Entities have been removed.
  • v1.2.1 Release Notes
    • Bug fix related to collecting group membership to retrieve users starting from index 0.
  • We are planning to install this plugin in our DEV environment. Can you please provide to details steps to install this in our dev environment?

  • Hi team,

    We are planning to use this app for collecting user activity. I have two questions about this app

    1. If we have HA setup with multiple appian nodes, will the collector collect users and login activity from all three of them?

    2. One observation I have through one of our recent collection, the number of users in LMA_ENVIRONMENT_USER table are one less than the actual active users in the environment. Anyone seen this type of issue? the user missing is active appian user since 2022, but i dont see any login done by the user. 

  • Hi team,

    We are trying to install this app on Appian 22.4 with MSSQL database. We were able to install collection app, also we have setup the database for Reporting app. But while importing the Reporting app, we are getting below error:

    processModel 0002e3e2-7e00-8000-5369-544d98544d98 "LMA Process Data Package": An error occurred while creating processModel [uuid=0002e3e2-7e00-8000-5369-544d98544d98]: com.appiancorp.process.validation.ValidationException: Process Model is not valid. Invalid Activity Class Schema ID (APNX-1-4071-007)

    We have tried this with Excel Tools 2.5.0 version. During inspection, it does not show any error but after importing it gives error.

  • 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

  • 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;
      }
    }

  • I have one that seem working for me but not sure that all is fully correct