com.streamreduce.core.service.InventoryServiceImpl.java Source code

Java tutorial

Introduction

Here is the source code for com.streamreduce.core.service.InventoryServiceImpl.java

Source

/*
 * Copyright 2012 Nodeable Inc
 *
 *    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 com.streamreduce.core.service;

import com.google.common.base.Objects;
import com.google.common.base.Preconditions;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.RemovalListener;
import com.google.common.cache.RemovalNotification;
import com.google.common.collect.ImmutableSet;
import com.mongodb.BasicDBObject;
import com.mongodb.DBObject;
import com.streamreduce.ConnectionNotFoundException;
import com.streamreduce.Constants;
import com.streamreduce.ProviderIdConstants;
import com.streamreduce.connections.ConnectionProviderFactory;
import com.streamreduce.core.CommandNotAllowedException;
import com.streamreduce.core.dao.DAODatasourceType;
import com.streamreduce.core.dao.GenericCollectionDAO;
import com.streamreduce.core.dao.InventoryItemDAO;
import com.streamreduce.core.event.EventId;
import com.streamreduce.core.model.Connection;
import com.streamreduce.core.model.Event;
import com.streamreduce.core.model.InventoryItem;
import com.streamreduce.core.model.SobaObject;
import com.streamreduce.core.model.User;
import com.streamreduce.core.model.messages.MessageType;
import com.streamreduce.core.model.messages.details.feed.FeedEntryDetails;
import com.streamreduce.core.model.messages.details.jira.JiraActivityDetails;
import com.streamreduce.core.model.messages.details.pingdom.PingdomEntryDetails;
import com.streamreduce.core.model.messages.details.twitter.TwitterActivityDetails;
import com.streamreduce.core.service.exception.InvalidCredentialsException;
import com.streamreduce.core.service.exception.InventoryItemNotFoundException;
import com.streamreduce.util.AWSClient;
import com.streamreduce.util.ExternalIntegrationClient;
import com.streamreduce.util.FeedClient;
import com.streamreduce.util.GitHubClient;
import com.streamreduce.util.GoogleAnalyticsClient;
import com.streamreduce.util.HashtagUtil;
import com.streamreduce.util.JSONUtils;
import com.streamreduce.util.JiraClient;
import com.streamreduce.util.MessageUtils;
import com.streamreduce.util.PingdomClient;
import com.streamreduce.util.TwitterClient;
import com.sun.syndication.feed.synd.SyndContent;
import com.sun.syndication.feed.synd.SyndEntry;
import com.sun.syndication.feed.synd.SyndFeed;
import com.sun.syndication.io.SyndFeedInput;
import com.sun.syndication.io.XmlReader;
import net.sf.json.JSONArray;
import net.sf.json.JSONObject;
import org.apache.abdera.model.Element;
import org.apache.abdera.model.Entry;
import org.bson.types.ObjectId;
import org.codehaus.jackson.map.ObjectMapper;
import org.jclouds.aws.ec2.domain.Tag;
import org.jclouds.cloudwatch.CloudWatchAsyncApi;
import org.jclouds.cloudwatch.CloudWatchApi;
import org.jclouds.cloudwatch.domain.*;
import org.jclouds.cloudwatch.features.MetricApi;
import org.jclouds.compute.domain.ComputeType;
import org.jclouds.compute.domain.NodeMetadata;
import org.jclouds.domain.Location;
import org.jclouds.domain.LocationBuilder;
import org.jclouds.domain.LocationScope;
import org.jclouds.rest.RestContext;
import org.joda.time.DateTime;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.io.IOException;
import java.net.URI;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import javax.xml.namespace.QName;

/**
 * Implementation of {@link InventoryService}.
 */
@Service("inventoryService")
public class InventoryServiceImpl implements InventoryService {

    protected transient Logger logger = LoggerFactory.getLogger(getClass());

    private Map<String, Unit> ec2CloudWatchMetricNames = null;
    private Set<Statistics> ec2CloudWatchStatisticsSet = ImmutableSet.of(Statistics.AVERAGE, Statistics.MINIMUM,
            Statistics.MAXIMUM);
    private final Cache<ObjectId, ExternalIntegrationClient> externalClientCache = CacheBuilder.newBuilder()
            .expireAfterWrite(3, TimeUnit.MINUTES)
            .removalListener(new RemovalListener<ObjectId, ExternalIntegrationClient>() {
                /**
                 * {@inheritDoc}
                 */
                @Override
                public void onRemoval(RemovalNotification<ObjectId, ExternalIntegrationClient> n) {
                    ExternalIntegrationClient client = n.getValue();

                    if (client != null) {
                        logger.debug("Cleaning up ExternalIntegrationClient [" + client.getConnectionId() + "]");
                        client.cleanUp();
                    }
                }
            }).build();

    @Autowired
    InventoryItemDAO inventoryItemDAO;
    @Autowired
    GenericCollectionDAO genericCollectionDAO;
    @Autowired
    EmailService emailService;
    @Autowired
    EventService eventService;
    @Autowired
    MessageService messageService;
    @Autowired
    ConnectionService connectionService;
    @Autowired
    ConnectionProviderFactory connectionProviderFactory;

    /**
     * {@inheritDoc}
     */
    @Override
    public BasicDBObject getInventoryItemPayload(InventoryItem inventoryItem) {
        return genericCollectionDAO.getById(DAODatasourceType.BUSINESS,
                Constants.INVENTORY_ITEM_METADATA_COLLECTION_NAME, inventoryItem.getMetadataId());
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public InventoryItem createInventoryItem(Connection connection, JSONObject json)
            throws ConnectionNotFoundException, InvalidCredentialsException, IOException {
        Preconditions.checkNotNull(connection, "connection cannot be null.");
        Preconditions.checkNotNull(json, "json cannot be null.");

        String providerId = connection.getProviderId();
        InventoryItem inventoryItem = new InventoryItem();

        inventoryItem.setAccount(connection.getAccount());
        inventoryItem.setUser(connection.getUser());
        inventoryItem.setConnection(connection);
        inventoryItem.addHashtag(connection.getProviderId());
        inventoryItem.setDeleted(false);

        // Default to visibility of the connection (Should be overridden below if required)
        inventoryItem.setVisibility(connection.getVisibility());

        // No other way...  polymorphism is Hard...
        if (providerId.equals(ProviderIdConstants.AWS_PROVIDER_ID)) {
            extendAWSInventoryItem(inventoryItem, json);
        } else if (providerId.equals(ProviderIdConstants.GITHUB_PROVIDER_ID)) {
            extendGitHubInventoryItem(inventoryItem, json);
        } else if (providerId.equals(ProviderIdConstants.GOOGLE_ANALYTICS_PROVIDER_ID)) {
            extendGoogleAnalyticsInventoryItem(inventoryItem, json);
        } else if (providerId.equals(ProviderIdConstants.JIRA_PROVIDER_ID)) {
            extendJiraInventoryItem(inventoryItem, json);
        } else if (providerId.equals(ProviderIdConstants.PINGDOM_PROVIDER_ID)) {
            extendPingdomInventoryItem(inventoryItem, json);
        } else if (providerId.equals(ProviderIdConstants.CUSTOM_PROVIDER_ID)) {
            extendGenericInventoryItem(inventoryItem, json);
        } else if (providerId.equals(ProviderIdConstants.NAGIOS_PROVIDER_ID)) {
            extendGenericInventoryItem(inventoryItem, json);
        } else {
            throw new IllegalArgumentException(providerId + " does not support creating inventory items.");
        }

        // Create metadata
        DBObject metadataEntry = genericCollectionDAO.createCollectionEntry(DAODatasourceType.BUSINESS,
                Constants.INVENTORY_ITEM_METADATA_COLLECTION_NAME, json.toString());

        inventoryItem.setMetadataId((ObjectId) metadataEntry.get("_id"));

        // Persist the inventory item
        inventoryItemDAO.save(inventoryItem);

        // Create the event
        Event event = eventService.createEvent(EventId.CREATE, inventoryItem, null);

        // Create the message
        messageService.sendInventoryMessage(event, inventoryItem);

        return inventoryItem;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public InventoryItem updateInventoryItem(InventoryItem inventoryItem, JSONObject json)
            throws ConnectionNotFoundException, InvalidCredentialsException, IOException {
        Preconditions.checkNotNull(inventoryItem, "inventoryItem cannot be null.");
        Preconditions.checkNotNull(json, "json cannot be null.");

        Connection connection = inventoryItem.getConnection();
        String providerId = connection.getProviderId();

        // No other way...
        if (providerId.equals(ProviderIdConstants.AWS_PROVIDER_ID)) {
            extendAWSInventoryItem(inventoryItem, json);
        } else if (providerId.equals(ProviderIdConstants.GITHUB_PROVIDER_ID)) {
            extendGitHubInventoryItem(inventoryItem, json);
        } else if (providerId.equals(ProviderIdConstants.GOOGLE_ANALYTICS_PROVIDER_ID)) {
            extendGoogleAnalyticsInventoryItem(inventoryItem, json);
        } else if (providerId.equals(ProviderIdConstants.JIRA_PROVIDER_ID)) {
            extendJiraInventoryItem(inventoryItem, json);
        } else if (providerId.equals(ProviderIdConstants.CUSTOM_PROVIDER_ID)) {
            extendGenericInventoryItem(inventoryItem, json);
        } else if (providerId.equals(ProviderIdConstants.NAGIOS_PROVIDER_ID)) {
            extendGenericInventoryItem(inventoryItem, json);
        } else if (providerId.equals(ProviderIdConstants.PINGDOM_PROVIDER_ID)) {
            extendPingdomInventoryItem(inventoryItem, json);
        } else {
            throw new IllegalArgumentException(providerId + " does not support creating inventory items.");
        }

        // Change visibility if necessary?

        // Update metadata
        try {
            genericCollectionDAO.updateCollectionEntry(DAODatasourceType.BUSINESS,
                    Constants.INVENTORY_ITEM_METADATA_COLLECTION_NAME, inventoryItem.getMetadataId(),
                    json.toString());
        } catch (Exception e) {
            // Should never happen
            logger.error("Error updating project hosting inventory item cache: " + inventoryItem.getId(), e);
        }

        // Send a silent update if none of our internal properties have changed
        boolean silentUpdate = false;
        InventoryItem oldInventoryItem;

        try {
            oldInventoryItem = getInventoryItem(inventoryItem.getId());
        } catch (InventoryItemNotFoundException e) {
            // Should never happen but just in case
            oldInventoryItem = null;
        }

        // Be silent unless alias, description, hashtags or visibility are different.  (None of the other properties
        // should be changeable externally or via our exposed APIs.)
        if (oldInventoryItem != null) {
            silentUpdate = (Objects.equal(oldInventoryItem.getAlias(), inventoryItem.getAlias())
                    && Objects.equal(oldInventoryItem.getDescription(), inventoryItem.getDescription())
                    && Objects.equal(oldInventoryItem.getHashtags(), inventoryItem.getHashtags())
                    && Objects.equal(oldInventoryItem.getVisibility(), inventoryItem.getVisibility()));
        }

        // Persist the inventory item
        return updateInventoryItem(inventoryItem, silentUpdate);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public InventoryItem updateInventoryItem(InventoryItem inventoryItem, boolean silentUpdate) {
        Preconditions.checkNotNull(inventoryItem, "inventoryItem cannot be null.");

        inventoryItemDAO.save(inventoryItem);

        if (!silentUpdate) {
            // Create the event
            Event event = eventService.createEvent(EventId.UPDATE, inventoryItem, null);

            // Create the message
            messageService.sendInventoryMessage(event, inventoryItem);
        }

        return inventoryItem;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void deleteInventoryItem(InventoryItem inventoryItem) {
        Preconditions.checkNotNull(inventoryItem, "inventoryItem cannot be null.");

        // Delete the metadata entry
        genericCollectionDAO.removeCollectionEntry(DAODatasourceType.BUSINESS,
                Constants.INVENTORY_ITEM_METADATA_COLLECTION_NAME, inventoryItem.getMetadataId());

        inventoryItemDAO.delete(inventoryItem);

        // If the item was already marked as deleted, do not resend the event/message
        if (inventoryItem.isDeleted()) {
            // Create the event
            Event event = eventService.createEvent(EventId.DELETE, inventoryItem, null);

            // Create the message
            messageService.sendInventoryMessage(event, inventoryItem);
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void markInventoryItemDeleted(InventoryItem inventoryItem) {
        inventoryItem.setDeleted(true);

        inventoryItemDAO.save(inventoryItem);

        // Create the event
        Event event = eventService.createEvent(EventId.DELETE, inventoryItem, null);

        // Create the message
        messageService.sendInventoryMessage(event, inventoryItem);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public List<InventoryItem> getInventoryItems(Connection connection) {
        return getInventoryItems(connection, null);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public List<InventoryItem> getInventoryItems(Connection connection, User user) {
        List<InventoryItem> inventoryItems = inventoryItemDAO.getInventoryItems(connection, user);

        for (InventoryItem inventoryItem : inventoryItems) {
            // Create the event
            eventService.createEvent(EventId.READ, inventoryItem, null);
        }

        return inventoryItems;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public List<InventoryItem> getInventoryItems(ObjectId connectionId) {
        List<InventoryItem> inventoryItems = inventoryItemDAO.getInventoryItems(connectionId);

        for (InventoryItem inventoryItem : inventoryItems) {
            // Create the event
            eventService.createEvent(EventId.READ, inventoryItem, null);
        }

        return inventoryItems;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public List<InventoryItem> getInventoryItemsForExternalId(String externalId) {
        List<InventoryItem> inventoryItems = inventoryItemDAO.getByExternalIdNotDeleted(externalId);

        for (InventoryItem inventoryItem : inventoryItems) {
            // Create the event
            eventService.createEvent(EventId.READ, inventoryItem, null);
        }

        return inventoryItems;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public InventoryItem getInventoryItem(ObjectId objectId) throws InventoryItemNotFoundException {
        InventoryItem inventoryItem = inventoryItemDAO.get(objectId);

        if (inventoryItem == null) {
            throw new InventoryItemNotFoundException(objectId.toString());
        }

        // Create the event
        eventService.createEvent(EventId.READ, inventoryItem, null);

        return inventoryItem;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public InventoryItem getInventoryItemForExternalId(Connection connection, String externalId)
            throws InventoryItemNotFoundException {
        Preconditions.checkNotNull(connection, "connection cannot be null.");
        Preconditions.checkNotNull(externalId, "externalId cannot be null.");

        InventoryItem inventoryItem = inventoryItemDAO.getInventoryItem(connection, externalId);

        if (inventoryItem == null) {
            throw new InventoryItemNotFoundException("No inventory item for connection [" + connection.getId()
                    + "] " + "with the given externalId of " + externalId);
        }

        // Create the event
        eventService.createEvent(EventId.READ, inventoryItem, null);

        return inventoryItem;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void refreshInventoryItemCache(Connection connection)
            throws ConnectionNotFoundException, InvalidCredentialsException, IOException {
        Preconditions.checkNotNull(connection, "connection cannot be null.");

        logger.debug("Updating inventory item cache for connection [" + connection.getId() + "]: "
                + connection.getAlias());

        List<String> processedKeys = new ArrayList<>();
        List<JSONObject> externalInventoryItems;
        ExternalIntegrationClient client = getClient(connection);

        if (client instanceof AWSClient) {
            AWSClient awsClient = null;
            try {
                awsClient = new AWSClient(connection);
                // Get the EC2 inventory items
                externalInventoryItems = (awsClient.getEC2Instances());
                // Get the S3 inventory items
                externalInventoryItems.addAll(awsClient.getS3BucketsAsJson());
            } finally {
                if (awsClient != null) {
                    awsClient.cleanUp();
                }
            }
        } else if (client instanceof GitHubClient) {
            externalInventoryItems = ((GitHubClient) client).getRepositories();
        } else if (client instanceof GoogleAnalyticsClient) {
            externalInventoryItems = ((GoogleAnalyticsClient) client).getProfiles();
        } else if (client instanceof JiraClient) {
            externalInventoryItems = ((JiraClient) client).getProjects(false);
        } else if (client instanceof PingdomClient) {
            externalInventoryItems = ((PingdomClient) client).checks();
        } else if (client instanceof FeedClient) {
            return;
        } else if (client instanceof TwitterClient) {
            return;
        } else {
            throw new IllegalArgumentException(
                    client.getClass().getName() + " is not a supported external client.");
        }

        logger.debug("  Provider id: " + connection.getProviderId());
        logger.debug("  Inventory items found: " + externalInventoryItems.size());

        for (JSONObject json : externalInventoryItems) {
            InventoryItem inventoryItem = null;
            JSONObject externalInventoryItemAsJSON;
            String externalId;

            if (client instanceof AWSClient) {
                String type = json.getString("type");

                if (type.equals(ComputeType.NODE.toString())) {
                    externalId = json.getString("providerId");
                } else {
                    externalId = json.getString("name");
                }
                externalInventoryItemAsJSON = json;
            } else if (client instanceof GitHubClient) {
                externalId = json.getJSONObject("owner").getString("login") + "/" + json.getString("name");
                externalInventoryItemAsJSON = json;
            } else if (client instanceof PingdomClient) {
                externalId = json.getString("id");
                externalInventoryItemAsJSON = json;
            } else if (client instanceof GoogleAnalyticsClient) {
                externalId = json.getString("id");
                externalInventoryItemAsJSON = json;
            } else {
                externalId = json.getString("key");
                try {
                    externalInventoryItemAsJSON = ((JiraClient) client).getProjectDetails(externalId);
                } catch (InvalidCredentialsException | IOException e) {
                    // This should never happen as we've already validated the connection by this point
                    logger.warn("Unable to get the Jira project details for " + externalId + ": " + e.getMessage());
                    return;
                }
            }

            try {
                inventoryItem = getInventoryItemForExternalId(connection, externalId);
            } catch (InventoryItemNotFoundException e) {
                // This is more than possible and is handled below
            }

            if (inventoryItem == null || inventoryItem.isDeleted()) {
                if (inventoryItem != null && inventoryItem.isDeleted()) {
                    // Permanently remove the current inventory item marked as deleted.  This can happen if you mark
                    // an inventory item as deleted (due to external deletion) and then recreate the same inventory
                    // item externally before the clean process (ran after refresh) could occur.
                    deleteInventoryItem(inventoryItem);
                }

                createInventoryItem(connection, externalInventoryItemAsJSON);
            } else if (!processedKeys.contains(externalId)) {
                updateInventoryItem(inventoryItem, externalInventoryItemAsJSON);
            }

            processedKeys.add(externalId);
        }

        // Handle inventory items deleted externally
        List<InventoryItem> inventoryItems = getInventoryItems(connection);

        for (InventoryItem inventoryItem : inventoryItems) {
            if (processedKeys.contains(inventoryItem.getExternalId())) {
                continue;
            }

            // Just mark the item as deleted.  Future polling jobs will remove the inventory item marked as deleted
            // when appropriate.
            if (!inventoryItem.isDeleted()) {
                markInventoryItemDeleted(inventoryItem);
            }
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void pullInventoryItemActivity(Connection connection)
            throws ConnectionNotFoundException, InvalidCredentialsException, IOException {
        Preconditions.checkNotNull(connection, "connection cannot be null.");

        String providerId = connection.getProviderId();

        // TODO: pullGitHubActivity and pullJiraActivity could be simplified

        // No other way...
        if (providerId.equals(ProviderIdConstants.AWS_PROVIDER_ID)) {
            pullEC2CloudWatchMetrics(connection);
            // AWS S3 doesn't do CloudWatch metrics so we can tackle "metrics" when we do the same for GitHub/Jira
        } else if (providerId.equals(ProviderIdConstants.GITHUB_PROVIDER_ID)) {
            pullGitHubActivity(connection);
        } else if (providerId.equals(ProviderIdConstants.GOOGLE_ANALYTICS_PROVIDER_ID)) {
            pullGoogleAnalyticsActivity(connection);
        } else if (providerId.equals(ProviderIdConstants.JIRA_PROVIDER_ID)) {
            pullJiraActivity(connection);
        } else if (providerId.equals(ProviderIdConstants.PINGDOM_PROVIDER_ID)) {
            pullPingdomActivity(connection);
        } else if (providerId.equals(ProviderIdConstants.FEED_PROVIDER_ID)) {
            pullFeedActivity(connection);
        } else if (providerId.equals(ProviderIdConstants.TWITTER_PROVIDER_ID)) {
            pullTwitterActivity(connection);
        } else {
            throw new IllegalArgumentException(providerId + " does not support polling for activity.");
        }
    }

    /* HELPERS METHODS */

    /**
     * {@inheritDoc}
     */
    @Override
    public String getComputeInstanceIPAddress(InventoryItem inventoryItem) {
        Preconditions.checkNotNull(inventoryItem, "inventoryItem cannot be null.");
        Preconditions.checkArgument(inventoryItem.getType().equals(Constants.COMPUTE_INSTANCE_TYPE),
                "Only inventory items of type '" + Constants.COMPUTE_INSTANCE_TYPE + "' can have an OS name.");

        DBObject nodeMetadata = genericCollectionDAO.getById(DAODatasourceType.BUSINESS,
                Constants.INVENTORY_ITEM_METADATA_COLLECTION_NAME, inventoryItem.getMetadataId());

        return getComputeInstanceIPAddress(
                nodeMetadata != null ? JSONObject.fromObject(nodeMetadata.toString()) : null);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public String getComputeInstanceIPAddress(JSONObject json) {
        Preconditions.checkNotNull(json, "json cannot be null.");
        Preconditions.checkArgument(json.getString("type").equals(ComputeType.NODE.toString()),
                "Only inventory items of type '" + Constants.COMPUTE_INSTANCE_TYPE + "' can have an OS name.");

        String ipAddress = null;

        if (json.containsKey("publicAddresses")) {
            JSONArray publicAddresses = json.getJSONArray("publicAddresses");

            // TODO: How do we want to handle multiple IP addresses?
            if (publicAddresses.size() > 0) {
                ipAddress = (String) publicAddresses.get(0);
            }
        }

        return ipAddress;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public String getComputeInstanceOSName(InventoryItem inventoryItem) {
        Preconditions.checkNotNull(inventoryItem, "inventoryItem cannot be null.");
        Preconditions.checkArgument(inventoryItem.getType().equals(Constants.COMPUTE_INSTANCE_TYPE),
                "Only inventory items of type '" + Constants.COMPUTE_INSTANCE_TYPE + "' can have an OS name.");

        DBObject nodeMetadata = genericCollectionDAO.getById(DAODatasourceType.BUSINESS,
                Constants.INVENTORY_ITEM_METADATA_COLLECTION_NAME, inventoryItem.getMetadataId());

        return getComputeInstanceOSName(
                nodeMetadata != null ? JSONObject.fromObject(nodeMetadata.toString()) : null);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public String getComputeInstanceOSName(JSONObject json) {
        Preconditions.checkNotNull(json, "json cannot be null.");
        Preconditions.checkArgument(json.getString("type").equals(ComputeType.NODE.toString()),
                "Only inventory items of type '" + Constants.COMPUTE_INSTANCE_TYPE + "' can have an OS name.");

        String osName = "UNKNOWN";

        if (json.containsKey("operatingSystem")) {
            JSONObject operatingSystem = json.getJSONObject("operatingSystem");

            if (operatingSystem != null && operatingSystem.containsKey("family")) {
                String rawOSName = operatingSystem.getString("family");
                if (rawOSName != null && !(rawOSName.equalsIgnoreCase("UNRECOGNIZED"))) {
                    osName = rawOSName;
                }
            }
        }

        return osName;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Location getLocationByScope(InventoryItem inventoryItem, LocationScope scope) {
        Preconditions.checkNotNull(inventoryItem, "inventoryItem cannot be null.");
        Preconditions.checkNotNull(scope, "scope cannot be null.");

        DBObject nodeMetadata = genericCollectionDAO.getById(DAODatasourceType.BUSINESS,
                Constants.INVENTORY_ITEM_METADATA_COLLECTION_NAME, inventoryItem.getMetadataId());

        return getLocationByScope(nodeMetadata != null ? JSONObject.fromObject(nodeMetadata.toString()) : null,
                scope);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public Location getLocationByScope(JSONObject json, LocationScope scope) {
        Preconditions.checkNotNull(json, "json cannot be null.");
        Preconditions.checkNotNull(scope, "scope cannot be null.");
        Preconditions.checkArgument(json.containsKey("location"), "json must contain a 'location' attribute.");

        JSONObject locationObject = json.containsKey("location") && json.get("location") != null
                ? json.getJSONObject("location")
                : null;
        Location location = null;

        while (locationObject != null) {
            if (locationObject.containsKey("scope")) {
                String locationScope = locationObject.getString("scope");

                if (locationScope != null) {
                    if (scope == LocationScope.valueOf(locationScope)) {
                        location = buildLocationFromJSON(locationObject);
                        break;
                    }
                }
            }

            locationObject = locationObject.containsKey("parent") && locationObject.get("parent") != null
                    ? locationObject.getJSONObject("parent")
                    : null;
        }

        return location;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void addHashtag(InventoryItem inventoryItem, SobaObject tagger, String tag) {
        inventoryItem.addHashtag(tag);

        handleHashtagEvent(EventId.HASHTAG_ADD, inventoryItem, tagger, tag);

        updateInventoryItem(inventoryItem, true);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void removeHashtag(InventoryItem inventoryItem, SobaObject tagger, String tag) {
        inventoryItem.removeHashtag(tag);

        handleHashtagEvent(EventId.HASHTAG_DELETE, inventoryItem, tagger, tag);

        updateInventoryItem(inventoryItem, true);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void rebootComputeInstance(InventoryItem inventoryItem)
            throws CommandNotAllowedException, InvalidCredentialsException {
        Preconditions.checkNotNull(inventoryItem, "inventoryItem cannot be null.");
        Preconditions.checkArgument(inventoryItem.getType().equals(Constants.COMPUTE_INSTANCE_TYPE),
                "Inventory item of type '" + inventoryItem.getType() + "' cannot be rebooted.");

        AWSClient client = (AWSClient) getClient(inventoryItem.getConnection());

        logger.debug("Rebooting node: " + inventoryItem.getExternalId());

        BasicDBObject payload = getInventoryItemPayload(inventoryItem);
        String jcloudsNodeId = payload.getString("id");
        NodeMetadata nodeMetadata = client.getEC2Instance(jcloudsNodeId);

        if (nodeMetadata.getStatus().equals(NodeMetadata.Status.TERMINATED)) {
            throw new CommandNotAllowedException("You cannot reboot a terminated node.");
        }

        EventId eventId;

        if (client.rebootEC2Instance(jcloudsNodeId)) {
            eventId = EventId.CLOUD_INVENTORY_ITEM_REBOOT;
        } else {
            // TODO: Handle this issue but it can be a false positive if the time it takes surpasses the time we wait
            eventId = EventId.CLOUD_INVENTORY_ITEM_REBOOT_FAILURE;
        }

        // Create the event
        Event event = eventService.createEvent(eventId, inventoryItem, null);
        // Create the message
        messageService.sendInventoryMessage(event, inventoryItem);
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void destroyComputeInstance(InventoryItem inventoryItem) throws InvalidCredentialsException {
        Preconditions.checkNotNull(inventoryItem, "inventoryItem cannot be null.");
        Preconditions.checkArgument(inventoryItem.getType().equals(Constants.COMPUTE_INSTANCE_TYPE),
                "Inventory item of type '" + inventoryItem.getType() + "' cannot be destroyed.");

        AWSClient client = (AWSClient) getClient(inventoryItem.getConnection());

        logger.debug("Terminating node: " + inventoryItem.getExternalId());

        BasicDBObject payload = getInventoryItemPayload(inventoryItem);
        String jcloudsNodeId = payload.getString("id");
        NodeMetadata nodeMetadata = client.getEC2Instance(jcloudsNodeId);

        if (nodeMetadata.getStatus().equals(NodeMetadata.Status.TERMINATED)) {
            return;
        }

        EventId eventId;

        if (client.destroyEC2Instance(jcloudsNodeId)) {
            eventId = EventId.CLOUD_INVENTORY_ITEM_TERMINATE;
        } else {
            // TODO: Handle this issue but it can be a false positive if the time it takes surpasses the time we wait
            eventId = EventId.CLOUD_INVENTORY_ITEM_TERMINATE_FAILURE;
        }

        // Create the event
        Event event = eventService.createEvent(eventId, inventoryItem, null);
        // Create the message
        messageService.sendInventoryMessage(event, inventoryItem);
    }

    /* PRIVATE METHODS */

    /**
     * Takes the passed in JSON and creates a {@link Location} from it.  (Does not include parents or children.)
     *
     * @param json the JSON that should represent a location
     *
     * @return the location
     */
    private Location buildLocationFromJSON(JSONObject json) {
        LocationBuilder builder = new LocationBuilder();
        Set<String> iso3166Codes = new HashSet<>();
        Map<String, Object> metadata = new HashMap<>();

        if (json.containsKey("iso3166Codes")) {
            for (Object rawCode : json.getJSONArray("iso3166Codes")) {
                iso3166Codes.add((String) rawCode);
            }
        }

        if (json.containsKey("metadata")) {
            JSONObject rawMetadata = json.getJSONObject("metadata");

            for (Object rawKey : rawMetadata.keySet()) {
                metadata.put((String) rawKey, rawMetadata.get(rawKey));
            }
        }

        builder.description(json.containsKey("description") ? json.getString("description") : null)
                .iso3166Codes(iso3166Codes).id(json.containsKey("id") ? json.getString("id") : null)
                .scope(json.containsKey("scope") ? LocationScope.valueOf(json.getString("scope")) : null)
                .metadata(metadata);

        return builder.build();
    }

    /**
     * Takes the inventory item already constructed in
     * {@link #createInventoryItem(com.streamreduce.core.model.Connection, net.sf.json.JSONObject)} and updates its
     * information with data in JSON.
     *
     * @param inventoryItem the inventory item
     * @param json the json representing the inventory item (from jclouds)
     *
     * @throws InvalidCredentialsException if the connection's credentials are invalid
     */
    private void extendAWSInventoryItem(InventoryItem inventoryItem, JSONObject json)
            throws InvalidCredentialsException {
        String externalType = json.getString("type");
        Connection connection = inventoryItem.getConnection();
        String externalId = inventoryItem.getExternalId();
        String name = inventoryItem.getAlias();
        String internalType;

        if (externalId == null) {
            if (externalType.equals(ComputeType.NODE.toString())) {
                externalId = json.getString("providerId");
            } else {
                externalId = json.getString("name");
            }
        }

        if (externalType.equals(ComputeType.NODE.toString())) {
            name = json.getString("name");
            AWSClient client = null;
            try {
                client = new AWSClient(inventoryItem.getConnection());
                Set<Tag> ec2Tags = client.getEC2InstanceTags(externalId);

                // Handle adding new hashtags based on the EC2 tags and the instance name
                for (Tag tag : ec2Tags) {
                    String key = tag.getKey();
                    String value = tag.getValue();

                    if (key.equals("Name")) {
                        if (value.length() > 0) {
                            name = value;
                        }
                    } else {
                        inventoryItem.addHashtag(key + "=" + value);
                    }
                }
            } finally {
                if (client != null) {
                    client.cleanUp();
                }
            }

            internalType = Constants.COMPUTE_INSTANCE_TYPE;
        } else {
            internalType = Constants.BUCKET_TYPE;
        }

        if (!StringUtils.hasText(name)) {
            name = externalId;
        }

        inventoryItem.setAlias(name);
        inventoryItem.setExternalId(externalId);
        inventoryItem.setType(internalType);

        inventoryItem.addHashtag(externalId);
        inventoryItem.addHashtag(internalType);

        // For all of AWS, we add hashtags for the availability zone and region
        Location region = getLocationByScope(json, LocationScope.REGION);
        Location zone = getLocationByScope(json, LocationScope.ZONE);

        if (region != null) {
            inventoryItem.addHashtag(region.getId());
        }
        if (zone != null) {
            inventoryItem.addHashtag(zone.getId());
        }

        // For AWS EC2, we add OS name
        if (externalType.equals(ComputeType.NODE.toString())) {
            String osName = getComputeInstanceOSName(json);

            if (osName != null) {
                inventoryItem.addHashtag(getComputeInstanceOSName(json));
            }
        }
    }

    /**
     * Takes the inventory item already constructed in
     * {@link #createInventoryItem(com.streamreduce.core.model.Connection, net.sf.json.JSONObject)} and updates its
     * information with data in JSON.
     *
     * @param inventoryItem the inventory item
     * @param json the json representing the inventory item (from jclouds)
     */
    private void extendGitHubInventoryItem(InventoryItem inventoryItem, JSONObject json) {
        boolean isFork = json.getBoolean("fork");
        boolean isPrivate = json.getBoolean("private");
        String language = json.getString("language");
        String name = json.getString("name");
        String owner = json.getJSONObject("owner").getString("login");
        String externalId = owner + "/" + name;
        String description = json.getString("description");

        if (description.length() > 256) {
            description = description.substring(0, 255);
        }

        inventoryItem.setAlias(externalId);
        inventoryItem.setDescription(description);
        inventoryItem.setExternalId(externalId);
        inventoryItem.setType(Constants.PROJECT_TYPE);

        // Override default visibility, we default to hidden if the repository is private
        if (isPrivate) {
            inventoryItem.setVisibility(SobaObject.Visibility.SELF);
        }

        // For GitHub, we add hashtags indicating the language and whether or not it is a fork
        if (isFork) {
            inventoryItem.addHashtag("fork");
        }

        if (StringUtils.hasText(language) && !language.equals("null")) {
            inventoryItem.addHashtag(language);
        }

        inventoryItem.addHashtag(inventoryItem.getType());
    }

    /**
     * Takes the inventory item already constructed in
     * {@link #createInventoryItem(com.streamreduce.core.model.Connection, net.sf.json.JSONObject)} and updates its
     * information with data in JSON.
     *
     * @param inventoryItem the inventory item
     * @param json the json representing the inventory item (from Google Analytics)
     */
    private void extendGoogleAnalyticsInventoryItem(InventoryItem inventoryItem, JSONObject json) {
        String name = json.getString("name");
        String externalId = json.getString("id");

        inventoryItem.setAlias(name);
        inventoryItem.setExternalId(externalId);
        inventoryItem.setType(Constants.ANALYTICS_TYPE);

        inventoryItem.addHashtag(inventoryItem.getType());
    }

    /**
     * Takes the inventory item already constructed in
     * {@link #createInventoryItem(com.streamreduce.core.model.Connection, net.sf.json.JSONObject)} and updates its
     * information with data in JSON.
     *
     * @param inventoryItem the inventory item
     * @param json the json representing the inventory item (from jclouds)
     *
     * @throws InvalidCredentialsException if the connection's credentials are invalid
     */
    private void extendJiraInventoryItem(InventoryItem inventoryItem, JSONObject json)
            throws InvalidCredentialsException, IOException {
        Connection connection = inventoryItem.getConnection();
        ObjectId connectionId = connection.getId();
        String key = json.getString("key");
        String name = json.getString("name");
        String description = json.getString("description");
        ExternalIntegrationClient rawClient = externalClientCache.getIfPresent(connectionId);
        JiraClient client;

        if (rawClient == null) {
            client = new JiraClient(connection);

            externalClientCache.put(connectionId, client);
        } else {
            client = (JiraClient) rawClient;
        }

        if (description.length() > 256) {
            description = description.substring(0, 255);
        }

        inventoryItem.setAlias(name != null ? name : key);
        inventoryItem.setDescription(description);
        inventoryItem.setExternalId(key);
        inventoryItem.setType(Constants.PROJECT_TYPE);

        // Override default visibility, we default to hidden if the project is not public
        if (!client.isProjectPublic(key)) {
            inventoryItem.setVisibility(SobaObject.Visibility.SELF);
        }

        inventoryItem.addHashtag(inventoryItem.getType());

        // We are unable to gather any extra hashtags to apply to Jira projects at this time
    }

    /**
     * Takes the inventory item already constructed in
     * {@link #createInventoryItem(com.streamreduce.core.model.Connection, net.sf.json.JSONObject)} and updates its
     * information with data in JSON.
     *
     * @param inventoryItem the inventory item
     * @param json the json representing the inventory item (from jclouds)
     *
     * @throws InvalidCredentialsException if the connection's credentials are invalid
     */
    private void extendPingdomInventoryItem(InventoryItem inventoryItem, JSONObject json) {
        String externalId = json.getString("id");
        inventoryItem.setAlias(json.getString("name"));
        inventoryItem.setDescription(json.getString("hostname"));
        inventoryItem.setExternalId(externalId);
        inventoryItem.setType(Constants.MONITOR_TYPE);
        inventoryItem.addHashtag(json.getString("type")); // http, dns, tcp, etc...
        inventoryItem.addHashtag(inventoryItem.getType());
    }

    /**
     * Takes the inventory item already constructed in
     * {@link #createInventoryItem(com.streamreduce.core.model.Connection, net.sf.json.JSONObject)} and updates its
     * information with data in JSON.
     *
     * @param inventoryItem the inventory item
     * @param json the json representing the inventory item (from jclouds)
     *
     * @throws InvalidCredentialsException if the connection's credentials are invalid
     */
    private void extendGenericInventoryItem(InventoryItem inventoryItem, JSONObject json) {
        String externalId = json.getString("inventoryItemId");
        JSONArray hashtags = json.containsKey("hashtags") ? json.getJSONArray("hashtags") : new JSONArray();

        if (!StringUtils.hasText(inventoryItem.getAlias())) {
            inventoryItem.setAlias(externalId);
        }

        if (!StringUtils.hasText(inventoryItem.getDescription())) {
            inventoryItem.setDescription("Created automatically from IMG activity message.");
        }

        inventoryItem.setExternalId(externalId);
        inventoryItem.setType(Constants.CUSTOM_TYPE); // Should we support the user specifying a type instead of hard coding?

        for (Object rawHashtag : hashtags) {
            if (rawHashtag != null && !rawHashtag.toString().equals("null")) {
                inventoryItem.addHashtag(HashtagUtil.normalizeTag(rawHashtag.toString()));
            }
        }

        inventoryItem.addHashtag(inventoryItem.getType());
    }

    /**
     * Simple helper to send hashtag events whenever
     * {@link #addHashtag(com.streamreduce.core.model.Taggable, com.streamreduce.core.model.SobaObject, String)} or
     * {@link #removeHashtag(com.streamreduce.core.model.Taggable, com.streamreduce.core.model.SobaObject, String)} is called.
     *
     * @param eventId the event id to use for the created event
     * @param inventoryItem the inventory item the event happened on
     * @param tagger the object that created the tag
     * @param tag the added/removed hashtag
     */
    private void handleHashtagEvent(EventId eventId, InventoryItem inventoryItem, SobaObject tagger, String tag) {
        // Create the event
        Map<String, Object> eventContext = new HashMap<>();

        if (eventId == EventId.HASHTAG_ADD) {
            eventContext.put("addedHashtag", tag);
        } else if (eventId == EventId.HASHTAG_DELETE) {
            eventContext.put("deletedHashtag", tag);
        }

        Event event = eventService.createEvent(eventId, inventoryItem, eventContext);

        // Create the message
        messageService.sendAccountMessage(event, tagger, inventoryItem.getConnection(), new Date().getTime(),
                MessageType.INVENTORY_ITEM, inventoryItem.getHashtags(), null);
    }

    private Map<String, InventoryItem> getInventoryItemMap(Connection connection) {
        List<InventoryItem> inventoryItems = getInventoryItems(connection);
        Map<String, InventoryItem> inventoryItemMap = new HashMap<>();

        for (InventoryItem inventoryItem : inventoryItems) {
            inventoryItemMap.put(inventoryItem.getExternalId(), inventoryItem);
        }

        return inventoryItemMap;
    }

    private void pullEC2CloudWatchMetrics(Connection connection)
            throws ConnectionNotFoundException, InvalidCredentialsException {
        // Right now our CloudWatch usage is pretty specific in that we're not exactly pulling all available AWS EC2
        // CloudWatch metrics and instead of relying on specific units for each metric name.  Eventually we could/should
        // just pull down everything available and go from there.
        if (ec2CloudWatchMetricNames == null) {
            ec2CloudWatchMetricNames = new HashMap<>();

            ec2CloudWatchMetricNames.put(EC2Constants.MetricName.CPU_UTILIZATION, Unit.PERCENT);
            ec2CloudWatchMetricNames.put(EC2Constants.MetricName.DISK_READ_BYTES, Unit.BYTES);
            ec2CloudWatchMetricNames.put(EC2Constants.MetricName.DISK_READ_OPS, Unit.COUNT);
            ec2CloudWatchMetricNames.put(EC2Constants.MetricName.DISK_WRITE_BYTES, Unit.BYTES);
            ec2CloudWatchMetricNames.put(EC2Constants.MetricName.DISK_WRITE_OPS, Unit.COUNT);
            ec2CloudWatchMetricNames.put(EC2Constants.MetricName.NETWORK_IN, Unit.BYTES);
            ec2CloudWatchMetricNames.put(EC2Constants.MetricName.NETWORK_OUT, Unit.BYTES);
        }

        try (RestContext<CloudWatchApi, CloudWatchAsyncApi> context = new AWSClient(connection)
                .getCloudWatchServiceContext()) {
            CloudWatchApi cloudWatchClient = context.getApi();
            List<InventoryItem> inventoryItems = getInventoryItems(connection);
            String metricNamespace = Namespaces.EC2;
            Calendar cal = Calendar.getInstance();
            Date endTime = new Date();
            Date startTime;

            cal.add(Calendar.MINUTE, -30);

            startTime = cal.getTime();

            for (InventoryItem inventoryItem : inventoryItems) {
                if (!inventoryItem.getType().equals(Constants.COMPUTE_INSTANCE_TYPE)) {
                    continue;
                }

                Map<String, JSONObject> metrics = new HashMap<>();
                String nodeId = inventoryItem.getExternalId();
                Location region = getLocationByScope(inventoryItem, LocationScope.REGION);

                if (region == null) {
                    continue;
                }

                String regionId = region.getId();
                Dimension dimension = new Dimension(EC2Constants.Dimension.INSTANCE_ID, nodeId);

                for (Map.Entry<String, Unit> ec2MetricEntry : ec2CloudWatchMetricNames.entrySet()) {
                    String metricName = ec2MetricEntry.getKey();
                    Unit metricUnit = ec2MetricEntry.getValue();
                    MetricApi metricClient = cloudWatchClient.getMetricApiForRegion(regionId);
                    GetMetricStatistics requestOptions = GetMetricStatistics.builder().namespace(metricNamespace)
                            .metricName(metricName).dimension(dimension).period(60)
                            .statistics(ec2CloudWatchStatisticsSet).startTime(startTime).endTime(endTime)
                            .unit(metricUnit).build();
                    GetMetricStatisticsResponse response = metricClient.getMetricStatistics(requestOptions);

                    // Per Gustavo's code, we're only adding the last metric
                    if (response != null && response.size() > 0) {
                        metrics.put(metricName, JSONObject.fromObject(response.iterator().next()));
                    }
                }

                if (!metrics.isEmpty()) {
                    Map<String, Object> eventContext = new HashMap<>();

                    eventContext.put("payload", metrics);
                    eventContext.put("isAgentActivity", false);

                    eventService.createEvent(EventId.ACTIVITY, inventoryItem, eventContext);
                }
            }
        }
    }

    private void pullGitHubActivity(Connection connection)
            throws ConnectionNotFoundException, InvalidCredentialsException, IOException {
        GitHubClient client = (GitHubClient) getClient(connection);
        Map<String, InventoryItem> inventoryItemMap = getInventoryItemMap(connection);
        List<JSONObject> feedEntries = client.getActivity(inventoryItemMap.keySet());
        Date lastActivityPoll = connection.getLastActivityPollDate();
        Date lastActivity = lastActivityPoll;

        try {
            for (JSONObject entry : feedEntries) {
                String projectKey = entry.getJSONObject("repo").getString("name");
                InventoryItem inventoryItem = inventoryItemMap.get(projectKey);

                if (inventoryItem == null) {
                    continue;
                }

                Date pubDate = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'")
                        .parse(entry.getString("created_at"));

                // Only create messages newer than the last activity poll date
                if (pubDate.before(lastActivityPoll)) {
                    continue;
                }

                if (pubDate.after(lastActivity)) {
                    lastActivity = pubDate;
                }

                Map<String, Object> activityParts = client.getPartsForActivity(inventoryItem, entry);

                // This can happen for unknown events which we log
                if (activityParts == null) {
                    // We have ran into a GitHub activity we do not know how to handle. Log the issue with as much
                    // detail as possible.
                    String entryAsJSON = entry.toString();

                    logger.error("Unable to parse GitHub activity to create activity message: " + entryAsJSON);

                    // Submit a bug report so we are aware of it.
                    emailService.sendBugReport(Constants.NODEABLE_SUPER_USERNAME,
                            Constants.NODEABLE_SUPER_ACCOUNT_NAME, "Unable to handle GitHub activity",
                            "There was a GitHub activity that we currently do not handle.", entryAsJSON);

                    // Should we create some specialized error message in the stream instead?

                    // Move on to the next activity entry
                    continue;
                }

                Map<String, Object> eventContext = new HashMap<>();

                eventContext.put("activityPubDate", pubDate);
                eventContext.put("activityTitle", MessageUtils.cleanEntry((String) activityParts.get("title")));
                eventContext.put("activityContent", MessageUtils.cleanEntry((String) activityParts.get("content")));
                eventContext.put("activityHashtags", activityParts.get("hashtags"));
                eventContext.put("payload", entry.toString());

                // Create the event stream entry
                Event event = eventService.createEvent(EventId.ACTIVITY, inventoryItem, eventContext);

                messageService.sendAccountMessage(event, inventoryItem, connection, pubDate.getTime(),
                        MessageType.ACTIVITY,
                        activityParts.get("hashtags") != null ? (Set<String>) activityParts.get("hashtags") : null,
                        null);
            }
        } catch (Exception e) {
            logger.error("Unknown exception occurred while pulling GitHub activity for connection ["
                    + connection.getId() + "]: " + e, e);
        } finally {
            // Update the connection's last polling time
            connection.setLastActivityPollDate(new Date(lastActivity.getTime() + 1));
            try {
                connectionService.updateConnection(connection, true);
            } catch (Exception e) {
                // This is a silent update to only update the last polling time so this should never throw an exception
            }
        }
    }

    private void pullGoogleAnalyticsActivity(Connection connection)
            throws ConnectionNotFoundException, InvalidCredentialsException, IOException {

        GoogleAnalyticsClient client = (GoogleAnalyticsClient) getClient(connection);
        Map<String, InventoryItem> inventoryItemMap = getInventoryItemMap(connection);
        List<JSONObject> profileMetricEntries = client.getAllProfileMetrics(inventoryItemMap.keySet());
        Date lastActivityPoll = connection.getLastActivityPollDate();
        Date lastActivity = lastActivityPoll;

        try {
            for (JSONObject entry : profileMetricEntries) {
                if (entry == null) {
                    continue;
                }

                String profileId = entry.getString("id");
                InventoryItem inventoryItem = inventoryItemMap.get(profileId);

                if (inventoryItem == null) {
                    continue;
                }

                JSONArray metrics = entry.getJSONArray("metrics");
                for (Object obj : metrics) {
                    JSONObject metric = (JSONObject) obj;

                    Map<String, Object> eventContext = new HashMap<>();
                    eventContext.put("activityTitle", String.format("%s on %s is at %s", metric.getString("metric"),
                            inventoryItem.getAlias(), metric.get("data")));
                    JSONArray jsonHashtags = metric.getJSONArray("hashtags");
                    Set<String> hashtags = new HashSet<String>(jsonHashtags);
                    eventContext.put("activityHashtags", hashtags);
                    eventContext.put("activityPayload", metric); /* need to fix this */

                    // Create the event stream entry
                    Event event = eventService.createEvent(EventId.ACTIVITY, inventoryItem, eventContext);

                    messageService.sendAccountMessage(event, inventoryItem, connection, System.currentTimeMillis(),
                            MessageType.ACTIVITY,
                            eventContext.get("activityHashtags") != null
                                    ? (Set<String>) eventContext.get("activityHashtags")
                                    : null,
                            null);
                }
            }
        } catch (Exception e) {
            logger.error("Unknown exception occurred while pulling Google Analytics activity for connection ["
                    + connection.getId() + "]: " + e, e);
        } finally {
            // Update the connection's last polling time
            connection.setLastActivityPollDate(new Date(lastActivity.getTime() + 1));
            try {
                connectionService.updateConnection(connection, true);
            } catch (Exception e) {
                // This is a silent update to only update the last polling time so this should never throw an exception
            }
        }
    }

    private void pullJiraActivity(Connection connection)
            throws ConnectionNotFoundException, InvalidCredentialsException, IOException {
        JiraClient client = (JiraClient) getClient(connection);
        Map<String, InventoryItem> inventoryItemMap = getInventoryItemMap(connection);
        Date lastActivityPoll = connection.getLastActivityPollDate();
        Date lastActivity = lastActivityPoll;

        try {
            List<Entry> feedEntries = client.getActivity(inventoryItemMap.keySet());
            if (feedEntries == null) {
                return;
            }

            for (Entry entry : feedEntries) {
                // To map project activity in Jira to a Nodeable ProjectHostingInventoryItem, we have
                // to do some magic.  Said magic is below.
                Element activityObject = entry
                        .getFirstChild(new QName("http://activitystrea.ms/spec/1.0/", "object", "activity"));
                String projectKey = client.getProjectKeyOfEntry(activityObject, inventoryItemMap.keySet());

                if (projectKey == null) {
                    // If the projectKey is null here, this means we've gotten activity for a project we're monitoring
                    // but we were unable to map said activity to the project in question.  This is a known issue and
                    // typically only seen in non-hosted Jira environments where people link their Jira project to other
                    // Atlassian products but do not use the same key for the Jira project and the project in the other
                    // Atlassian application.  (SOBA-1193)  Let's go ahead and log it so we do not forget but this is a
                    // known issue and should not become a ZenDesk ticket.
                    logger.error("Project key for Jira activity was unable to be found, possibly related to "
                            + "SOBA-1193: " + entry.toString().substring(0, 140));

                    // Move on to the next activity entry
                    continue;
                }

                InventoryItem inventoryItem = inventoryItemMap.get(projectKey);

                // This can happen if the activity is from a project, or Jira Studio product, not associated with a
                // project in our inventory system.  (A good example of this is wiki changes.  Each Jira Studio project
                // gets its own wiki but you can create new wiki spaces that are not associated with a Jira Studio
                // project and will end up without an inventory item in our system.)
                if (inventoryItem == null) {
                    logger.error(
                            "Project with key of " + projectKey + " did not correspond with an inventory item, "
                                    + "possibley related to SOBA-1193: " + entry.toString().substring(0, 140));

                    // Move on to the next activity entry
                    continue;
                }

                Date pubDate = entry.getPublished();

                // Only create messages newer than the last activity poll date
                if (pubDate.before(lastActivityPoll)) {
                    continue;
                }

                if (pubDate.after(lastActivity)) {
                    lastActivity = pubDate;
                }

                Map<String, Object> activityParts = client.getPartsForActivity(inventoryItem, entry);

                // This can happen for unknown events which we log
                if (activityParts == null) {
                    // We have ran into a Jira activity we do not know how to handle. Log the issue with as much
                    // detail as possible.
                    String entryAsJSON = entry.toString();

                    logger.error("Unable to parse Jira activity to create activity message: " + entryAsJSON);

                    // Submit a but report so we are aware of it.
                    emailService.sendBugReport(Constants.NODEABLE_SUPER_USERNAME,
                            Constants.NODEABLE_SUPER_ACCOUNT_NAME, "Unable to handle Jira activity",
                            "There was a Jira activity that we currently do not handle.", entryAsJSON);

                    // Should we create some specialized error message in the stream instead?

                    // Move on to the next activity entry
                    continue;
                }

                Map<String, Object> eventContext = new HashMap<>();

                eventContext.put("activityPubDate", pubDate);
                eventContext.put("activityTitle", MessageUtils.cleanEntry((String) activityParts.get("title")));
                eventContext.put("activityContent", MessageUtils.cleanEntry((String) activityParts.get("content")));
                eventContext.put("activityHashtags", activityParts.get("hashtags"));
                eventContext.put("payload", JSONUtils.xmlToJSON(entry.toString()).toString());

                // Create the event stream entry
                Event event = eventService.createEvent(EventId.ACTIVITY, inventoryItem, eventContext);

                JiraActivityDetails details = getJiraActivityDetailsFromActivityParts(activityParts);

                messageService.sendAccountMessage(event, inventoryItem, connection, pubDate.getTime(),
                        MessageType.ACTIVITY,
                        activityParts.get("hashtags") != null ? (Set<String>) activityParts.get("hashtags") : null,
                        details);
            }

        } catch (Exception e) {
            logger.error("Unknown exception occurred while pulling Jira activity for connection ["
                    + connection.getId() + "]: " + e, e);
        } finally {
            // Update the connection's last polling time
            connection.setLastActivityPollDate(new Date(lastActivity.getTime() + 1));
            try {
                connectionService.updateConnection(connection, true);
            } catch (Exception e) {
                // This is a silent update to only update the last polling time so this should never throw an exception
            }
        }
    }

    private void pullFeedActivity(Connection connection)
            throws ConnectionNotFoundException, InvalidCredentialsException, IOException {
        Date lastActivityPoll = connection.getLastActivityPollDate();
        Date lastActivity = lastActivityPoll;

        if (lastActivityPoll != null) {
            logger.debug("Creating feed messages for messages newer than (" + lastActivityPoll + ") for ["
                    + connection.getId() + "]: " + connection.getAlias());
        } else {
            logger.debug("Creating feed messages for all messages [" + connection.getId() + "]: "
                    + connection.getAlias());
        }

        try (XmlReader xmlReader = new XmlReader(URI.create(connection.getUrl()).toURL())) {
            SyndFeed rssFeed = new SyndFeedInput().build(xmlReader);
            List feedEntries = rssFeed.getEntries();

            Collections.sort(feedEntries, new Comparator<Object>() {
                @Override
                public int compare(Object first, Object second) {
                    SyndEntry firstEntry = (SyndEntry) first;
                    SyndEntry secondEntry = (SyndEntry) second;
                    Date firstDate = firstEntry.getPublishedDate() != null ? firstEntry.getPublishedDate()
                            : new Date();
                    Date secondDate = secondEntry.getPublishedDate() != null ? secondEntry.getPublishedDate()
                            : new Date();

                    return firstDate.compareTo(secondDate);
                }
            });

            for (Object rawEntry : feedEntries) {
                SyndEntry entry = (SyndEntry) rawEntry;

                //use published date if it exists... otherwise don't process the message as it is an update
                //this skips feed messages from feeds that don't include a publishedDate
                Date pubDate = entry.getPublishedDate();
                if (pubDate == null || pubDate.before(lastActivityPoll)) {
                    continue;
                }

                lastActivity = pubDate.after(lastActivity) ? pubDate : lastActivity;

                Map<String, Object> eventContext = new HashMap<>();
                String messageBodyAsJson = determineMessageBodyAsJsonFromSyndEntry(entry);

                eventContext.put("activityPubDate", pubDate);
                eventContext.put("activityTitle", entry.getTitle());
                eventContext.put("payload", messageBodyAsJson);

                Event event = eventService.createEvent(EventId.ACTIVITY, connection, eventContext);

                FeedEntryDetails details = new FeedEntryDetails.Builder().url(entry.getUri())
                        .title(entry.getTitle())
                        .description(entry.getDescription() != null ? entry.getDescription().getValue() : null)
                        .publishedDate(pubDate).build();

                // Create a new message to be delivered to inboxes
                messageService.sendActivityMessage(event, connection, pubDate.getTime(), details);
            }
        } catch (IOException e) {
            logger.error(String.format("Error opening the connection %s for feed %s. Returned error: %s",
                    connection.getId(), connection.getUrl(), e.getMessage()));
        } catch (Exception e) {
            logger.error(
                    String.format("Unable to process messages for connection %s with feed %s. Returned error: %s",
                            connection.getId(), connection.getUrl(), e.getMessage()));
        } finally {
            // Update the connection's last polling timeconnection.setLastActivityPollDate(new Date(lastActivity.getTime() + 1));
            try {
                connectionService.updateConnection(connection, true);
            } catch (Exception e) {
                // This is a silent update to only update the last polling time so this should never throw an exception
            }
        }
    }

    private void pullPingdomActivity(Connection connection)
            throws ConnectionNotFoundException, InvalidCredentialsException, IOException {
        PingdomClient client = (PingdomClient) getClient(connection);
        Map<String, InventoryItem> inventoryItemMap = getInventoryItemMap(connection);
        Date lastActivityPoll = connection.getLastActivityPollDate();

        if (lastActivityPoll != null) {
            logger.debug("Creating Pingdom messages for messages newer than (" + lastActivityPoll + ") for ["
                    + connection.getId() + "]: " + connection.getAlias());
        } else {
            logger.debug("Creating Pingdom messages for all messages [" + connection.getId() + "]: "
                    + connection.getAlias());
        }

        try {
            List<JSONObject> jsonInventoryList = client.checks();

            for (Iterator<JSONObject> i = jsonInventoryList.iterator(); i.hasNext();) {
                JSONObject jsonInventory = i.next();
                InventoryItem inventoryItem = inventoryItemMap.get(jsonInventory.getString("id"));

                if (inventoryItem == null) {
                    continue;
                }

                // the connection may not have been tested yet from Pingdom
                if (!jsonInventory.containsKey("lasttesttime")) {
                    continue;
                }

                Date lastTestTime = new DateTime().withMillis(0).plusSeconds(jsonInventory.getInt("lasttesttime"))
                        .toDate();
                if (lastActivityPoll != null && lastActivityPoll.after(lastTestTime)) {
                    continue;
                }

                lastActivityPoll = lastTestTime.after(lastActivityPoll) ? lastTestTime : lastActivityPoll;

                Map<String, Object> eventContext = new HashMap<>();

                eventContext.put("activityPubDate", lastTestTime);
                eventContext.put("activityTitle",
                        String.format("Service check %s (%s) has a response time of %dms.",
                                jsonInventory.getString("name"), jsonInventory.getString("type").toUpperCase(),
                                jsonInventory.getInt("lastresponsetime")));
                eventContext.put("payload", jsonInventory);

                if (jsonInventory.containsKey("lastresponsetime")) {
                    eventContext.put("lastResponseTime", jsonInventory.getInt("lastresponsetime"));
                }

                Event event = eventService.createEvent(EventId.ACTIVITY, inventoryItem, eventContext);

                PingdomEntryDetails details = new PingdomEntryDetails.Builder().lastErrorTime(
                        jsonInventory.containsKey("lasterrortime") ? jsonInventory.getInt("lasterrortime") : 0)
                        .lastResponseTime(jsonInventory.containsKey("lastresponsetime")
                                ? jsonInventory.getInt("lastresponsetime")
                                : 0)
                        .lastTestTime(jsonInventory.getInt("lasttesttime"))
                        .checkCreated(jsonInventory.getInt("created"))
                        .resolution(jsonInventory.getInt("resolution")).status(jsonInventory.getString("status"))
                        .build();

                // Create a new message to be delivered to inboxes
                messageService.sendAccountMessage(event, inventoryItem, connection, lastTestTime.getTime(),
                        MessageType.ACTIVITY, inventoryItem.getHashtags(), details);
            }

            // Update the connection's last polling time
            connection.setLastActivityPollDate(new Date(lastActivityPoll.getTime() + 1));

            connectionService.updateConnection(connection, true);
        } catch (IOException e) {
            logger.error(String.format("Error opening the connection %s for feed %s. Returned error: %s",
                    connection.getId(), connection.getUrl(), e.getMessage()));
        } catch (Exception e) {
            logger.error(
                    String.format("Unable to process messages for connection %s with feed %s. Returned error: %s",
                            connection.getId(), connection.getUrl(), e.getMessage()));
        }
    }

    private void pullTwitterActivity(Connection connection) {
        TwitterClient client = (TwitterClient) getClient(connection);

        try {
            JSONObject profile = client.getLoggedInProfile();

            if (profile == null) {
                logger.error("User's profile for Twitter connection %s came back null.", connection.getId());
                return;
            }

            Map<String, Object> eventContext = new HashMap<>();
            int favoritesCount = profile.containsKey("favourites_count") ? profile.getInt("favourites_count") : 0;
            int followersCount = profile.containsKey("followers_count") ? profile.getInt("followers_count") : 0;
            int friendsCount = profile.containsKey("friends_count") ? profile.getInt("friends_count") : 0;
            int listedCount = profile.containsKey("listed_count") ? profile.getInt("listed_count") : 0;
            int statusesCount = profile.containsKey("statuses_count") ? profile.getInt("statuses_count") : 0;
            String screenName = profile.containsKey("screen_name") ? profile.getString("screen_name") : "unknown";
            String name = profile.containsKey("name") ? profile.getString("name") : "Unknown";

            // Create a "title" for the activity (Shouldn't be here but without refactoring message transformation, it
            // is what it is.)
            String activityTitle = String.format("Twitter user stats for %s (%s)", screenName, name);
            String activityContent = String.format(
                    "Following %d users, has %d followers, has tweeted %d times, has "
                            + "created %d favorites and has been added to %d lists.",
                    friendsCount, followersCount, statusesCount, favoritesCount, listedCount);

            eventContext.put("activityTitle", activityTitle);
            eventContext.put("activityContent", activityContent);
            eventContext.put("payload", profile);

            Event event = eventService.createEvent(EventId.ACTIVITY, connection, eventContext);
            TwitterActivityDetails details = new TwitterActivityDetails.Builder().favorites(favoritesCount)
                    .followers(followersCount).friends(friendsCount).profile(profile).statuses(statusesCount)
                    .build();

            messageService.sendActivityMessage(event, connection, new Date().getTime(), details);
        } catch (Exception e) {
            logger.error(
                    String.format("Error getting the user's profile for Twitter connection %s. Returned error: %s",
                            connection.getId(), e.getMessage()));
        }
    }

    /**
     * Extracts a message body from a Rome SyndEntry object.  This handles calculating a message body from SyndEntry
     * objects that might either represent an entry in an RSS feed or an Atompub feed.
     *
     * @param entry SyndEntry to extract a message body from
     * @return JSON mapping of the description if it exists, or the first content element if there are contents.
     *         Otherwise an empty string is mapped to Json
     * @throws IOException when the SyndEntry fields can't be read or parsed to JSON.
     */
    private String determineMessageBodyAsJsonFromSyndEntry(SyndEntry entry) throws IOException {
        String body = ""; //default Body
        ObjectMapper om = new ObjectMapper();
        SyndContent desc = entry.getDescription();

        if (desc != null) {
            body = om.writeValueAsString(desc);
        } else if (entry.getContents().size() > 0) {
            body = om.writeValueAsString(entry.getContents().get(0)); //grab the first content item and use as body
        }

        return body;
    }

    /**
     * Creates a JiraActivityDetails object from the activityParts Map.  Specifically, this looks up the rawContent
     * part of Jira activity and stores in the html field of JiraActivities for display in rich messages.  Since it is
     * possible for jira messages to be extremely long (for instance, a commit message of a branch
     * that copies several thousand files will result in an html payload of tens of thousand of characters), this method
     * will return null so that activity details are not kept and clients will resort to non-richly formatted content.
     *
     * @param activityParts activityParts created from polling a jira instance
     * @return JiraActivityDetails with an html field if activityParts is not null, its "rawContent" key has a non-null
     * value, and the value of "rawContent" does not exceed a predefined number of characters.
     */
    private JiraActivityDetails getJiraActivityDetailsFromActivityParts(Map<String, Object> activityParts) {
        if (activityParts == null) {
            return null;
        }
        String rawContent = (String) activityParts.get("rawContent");
        if (rawContent == null || rawContent.length() > Constants.MAX_MESSAGE_LENGTH) {
            return null;
        }
        return new JiraActivityDetails.Builder().html((rawContent)).build();
    }

    private ExternalIntegrationClient getClient(Connection connection) {
        ExternalIntegrationClient client;
        client = externalClientCache.getIfPresent(connection.getId());
        if (client == null) {
            client = connectionProviderFactory
                    .externalIntegrationConnectionProviderFromId(connection.getProviderId()).getClient(connection);
            externalClientCache.put(connection.getId(), client);
        }

        return client;
    }
}