You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1287 lines
67 KiB
1287 lines
67 KiB
# Copyright (C) 2005-2024 Splunk Inc. All Rights Reserved.
|
|
"""
|
|
Contains the main methods and base class used for generating a shared base
|
|
search (a single saved search that works with multiple kpis)
|
|
Currently we only have support for Adhoc searches
|
|
TO THE FUTURE: if reasonable, make shared datamodel searches
|
|
a separate class
|
|
"""
|
|
|
|
import itsi_py3
|
|
from splunk import ResourceNotFound
|
|
from splunk.util import normalizeBoolean
|
|
from itsi_py3 import _
|
|
|
|
import ITOA.itoa_common as utils
|
|
from ITOA.storage import itoa_storage
|
|
from itsi.searches.itsi_filter import ItsiFilter
|
|
from itsi.objects.itsi_entity_filter import ItsiEntityFilterRule
|
|
from ITOA.setup_logging import logger
|
|
from ITOA.saved_search_utility import SavedSearch
|
|
|
|
|
|
class ItsiSharedAdhocSearch(utils.ItoaBase):
|
|
"""
|
|
Abstraction for anything related to ITSI Shared KPI base searches
|
|
Key component here is search generation for multiple KPIs using
|
|
a shared base search
|
|
"""
|
|
# Defined out here because of annoying kpi delete construction
|
|
search_prefix = 'Indicator - Shared - '
|
|
entity_magic = "%ENTITY_FILTER%"
|
|
|
|
def __init__(self, session_key, bs_id, base_search=None, services=None):
|
|
"""
|
|
Initializes the adhoc shared saved search
|
|
@param session_key: The splunkd session key
|
|
@param bs_id: The base search id
|
|
@param base_search: An optional parameter of the base search associated with the key
|
|
@param services: A list of service objects to be used instead of going to the kvstore
|
|
@type services: list
|
|
"""
|
|
super(ItsiSharedAdhocSearch, self).__init__(session_key)
|
|
self.backend = itoa_storage.ITOAStorage().get_backend(session_key)
|
|
|
|
self.shared_base_search_id = bs_id
|
|
if not isinstance(bs_id, itsi_py3.string_type):
|
|
raise Exception(_("KPI base search ID must be a valid string."))
|
|
if base_search is not None:
|
|
# If it doesnt exist, dont throw the exception
|
|
if base_search.get('_key', bs_id) != bs_id:
|
|
raise Exception(_("KPI base search passed in does not match key passed in."))
|
|
else:
|
|
# We need to look up the base search ourself
|
|
base_search = self.backend.get(session_key, 'nobody', 'kpi_base_search', bs_id)
|
|
if base_search is None:
|
|
raise Exception(_("Cannot find KPI base search with ID: %s.") % bs_id)
|
|
self.base_search = base_search
|
|
|
|
# determine if it's a metric base search
|
|
self.is_metric = normalizeBoolean(self.base_search.get("is_metric", False), False)
|
|
|
|
# At this point we have the shared base search
|
|
# Lookup all the services and kpis that use the shared base search
|
|
if services is None:
|
|
services = self.backend.get_all(session_key, 'nobody', 'service',
|
|
filter_data={"$and": [{"kpis.base_search_id": bs_id},
|
|
{"kpis.search_type": "shared_base"},
|
|
{"kpis.enabled": 1}]})
|
|
if len(services) == 0 or services is None:
|
|
# No services use this base search
|
|
# This MIGHT be an error or a warning, but I'm going
|
|
# To log it as warning for now, since this might be
|
|
# a normal mode of operation
|
|
logger.info("No services matched base search id %s" % bs_id)
|
|
services = [] # Disambiguation reassignment
|
|
|
|
# Create a structure that has all kpis from the services that we found
|
|
# And that makes service lookups a bit easier
|
|
self.kpis = {}
|
|
self.services = {}
|
|
for svc in services:
|
|
kpis = svc.get("kpis")
|
|
# This structure is a little easier to work with
|
|
service_key = svc.get("_key")
|
|
# Flag is kpis in service has shared search
|
|
is_found = False
|
|
for kpi in kpis:
|
|
# Front end sometimes does not update base_search_id so check for
|
|
# search type too
|
|
if kpi.get("base_search_id") == bs_id and kpi.get('search_type') == 'shared_base':
|
|
is_found = True
|
|
if service_key not in self.kpis:
|
|
self.kpis[service_key] = [kpi]
|
|
else:
|
|
self.kpis[service_key].append(kpi)
|
|
if is_found:
|
|
self.services[service_key] = svc
|
|
|
|
# Per ITOA-4442 Make a dictionary of "aliases". We'll need to carry this around so that when the situation
|
|
# arises. We can map to the appropriate metric
|
|
self.metric_aliases = {}
|
|
|
|
# PBL-5786: allow splitting of base search KPI by a different field than entity filter field. To support this,
|
|
# we need to perform pre stats calculation using 'sistats' command, since, entity aggregate values depend on
|
|
# multiple fields (breakdown and filter)
|
|
self.diff_entity_filter_breakdown_fields = False
|
|
if self.base_search.get("is_entity_breakdown", False) and \
|
|
self.base_search.get("is_service_entity_filter", False) and \
|
|
self.base_search.get("entity_id_fields", '') != self.base_search.get("entity_breakdown_id_fields", ''):
|
|
self.diff_entity_filter_breakdown_fields = True
|
|
|
|
self.unique_metric_tokens = {}
|
|
self.at_least_one_fill_gaps_metric = False
|
|
|
|
def upsert_service_for_preview(self, service):
|
|
"""
|
|
Used for previewing, upsert a service to the existing service list
|
|
NOTE to be VERY careful with this, it can make invalid entries
|
|
"""
|
|
if service.get('_key') is None:
|
|
# CAN BE NONE!! Unline other things
|
|
self.services["NO-KEY"] = service
|
|
else:
|
|
self.services[service['_key']] = service
|
|
|
|
@staticmethod
|
|
def can_optimize_entities(search_string):
|
|
"""
|
|
Examines the base search and determines if we can insert the entity filter clause
|
|
This is only a method because we may want to expand or fine tune the criteria
|
|
Cause, yeah, its basic.
|
|
@param
|
|
"""
|
|
# NOTE: It is also valid syntax if you have a subsearch followed by an entity filter optimization
|
|
# Since we don't know if macros can be automatically optimized, we dont automatically optimize on those either
|
|
if search_string.find("|") != -1 or search_string.find("`") != -1:
|
|
# If its a compounded search string, return false
|
|
return False
|
|
return True
|
|
|
|
@staticmethod
|
|
def append_entity_filter(search_string, entity_filter, metric=False):
|
|
"""
|
|
Determines if and how we can optimize a search string with a given entity filter
|
|
Looks for the magic number
|
|
"""
|
|
if ItsiSharedAdhocSearch.entity_magic in search_string:
|
|
# NOTE: Replaces ALL instances of the magic string with the entity filter
|
|
return search_string.replace(ItsiSharedAdhocSearch.entity_magic, entity_filter)
|
|
optimization = ItsiSharedAdhocSearch.can_optimize_entities(search_string)
|
|
if len(entity_filter) > 0:
|
|
if optimization or metric:
|
|
search_string += " " + entity_filter
|
|
else:
|
|
search_string += " | search " + entity_filter
|
|
return search_string
|
|
|
|
def get_saved_search_name(self):
|
|
"""
|
|
Return the name of the saved search
|
|
TODO: Determine if this should be converted into a static method
|
|
"""
|
|
return 'Indicator - Shared - ' + self.shared_base_search_id + ' - ITSI Search'
|
|
|
|
@staticmethod
|
|
def generate_search_dispatch_times(alert_lag, search_alert_earliest):
|
|
"""
|
|
Generate the dispatch settings used to calculate the earliest and latest
|
|
times to schedule the search.
|
|
|
|
@param alert_lag: The number of seconds to skew the search range
|
|
@type alert_lag: Int
|
|
@param search_alert_earliest: The number of seconds back to search
|
|
@type search_alert_earliest: Int
|
|
|
|
@return: A dict of settings representing the dispatch times
|
|
"""
|
|
settings = {}
|
|
if alert_lag == 0:
|
|
# Real Time case we need to set latest time to now
|
|
settings['dispatch.earliest_time'] = '-' + str(search_alert_earliest) + 's'
|
|
settings['dispatch.latest_time'] = 'now'
|
|
elif alert_lag <= 1800:
|
|
# Normal Case, adjust search timing to account for the lag
|
|
settings['dispatch.earliest_time'] = '-' + str(search_alert_earliest + alert_lag) + 's'
|
|
settings['dispatch.latest_time'] = '-' + str(alert_lag) + 's'
|
|
else:
|
|
raise ValueError(_("Invalid alert_lag passed to saved search management, must be below 30 minutes"))
|
|
return settings
|
|
|
|
##################################################################
|
|
# Splunk Search Methods
|
|
##################################################################
|
|
def build_splunk_search(self, description=None):
|
|
"""
|
|
Create the shared base search parameters. These are the generic defaults
|
|
passed in, and used by different searches
|
|
@param description: An alternate string description supplied
|
|
@type description: string
|
|
@return: The parameters of the shared base search
|
|
"""
|
|
saved_search_settings = {}
|
|
# Start off with the generic settings
|
|
saved_search_settings['name'] = self.get_saved_search_name()
|
|
if not isinstance(description, itsi_py3.string_type):
|
|
saved_search_settings['description'] = 'Auto generated shared base search'
|
|
else:
|
|
saved_search_settings['description'] = description
|
|
saved_search_settings['search'] = self.generate_shared_base_search_string()
|
|
|
|
# Calculate the dispatch timing
|
|
alert_lag = int(self.base_search.get('alert_lag', 30))
|
|
alert_earliest = int(self.base_search.get('search_alert_earliest', 5)) * 60
|
|
dispatch = ItsiSharedAdhocSearch.generate_search_dispatch_times(alert_lag, alert_earliest)
|
|
saved_search_settings.update(dispatch)
|
|
|
|
saved_search_settings['enableSched'] = '1'
|
|
|
|
# Perform REST call to fetch kpi saved search scheduling settings
|
|
sync_disabled = utils.get_saved_search_kpi_setting(self.session_key)
|
|
# Regenerate a random cron every time in order to take into account a change in the alert period
|
|
# Technically this means on save there is a potential for a kpi to execute slightly off rhythm at
|
|
# the point of save if the start point of the cron changes for a 5 or 15 period kpi
|
|
crontab = SavedSearch.generate_cron_schedule(self.base_search.get('alert_period', 5), sync_disabled)
|
|
saved_search_settings['cron_schedule'] = crontab
|
|
|
|
saved_search_settings['alert.suppress'] = '0'
|
|
saved_search_settings['alert.track'] = '0'
|
|
saved_search_settings['alert.digest_mode'] = '1'
|
|
|
|
saved_search_settings['actions'] = 'indicator'
|
|
saved_search_settings['action.indicator._itsi_base_search_id'] = self.shared_base_search_id
|
|
return saved_search_settings
|
|
|
|
# Create is also update
|
|
def create_splunk_search(self, ignore_service_check=False, acl_update=True):
|
|
"""
|
|
Create the shared base search that is associated with the shared_base_search_id of this search
|
|
@param ignore_service_check: Determine whether or not we should ignore the service check.
|
|
If there are no associated services don't create the base search
|
|
@type ignore_service_check: Boolean
|
|
@return: The parameters of the shared base search
|
|
"""
|
|
if not ignore_service_check and len(self.services) == 0:
|
|
logger.debug("Not issuing search for kpi_base_search=%s - No associated services"
|
|
% self.shared_base_search_id)
|
|
return True
|
|
# First a very simple check, if we don't have
|
|
settings = self.build_splunk_search()
|
|
ret = SavedSearch.update_search(self.session_key, settings.get('name'), 'itsi', 'nobody', **settings)
|
|
if ret:
|
|
# Successfully updated the saved search
|
|
logger.info("Successfully created/update saved search=%s", settings.get('name'))
|
|
if acl_update:
|
|
ret = SavedSearch.update_acl(
|
|
self.session_key,
|
|
settings.get('name'),
|
|
'nobody')
|
|
if not ret:
|
|
msg = _("ACL update failed for saved search %s. Manual update required.") % settings.get('name')
|
|
logger.error(msg)
|
|
else:
|
|
# Search creation failed
|
|
message = _("Failed to create saved search %s.") % settings.get('name')
|
|
logger.error(message)
|
|
raise Exception(message)
|
|
return ret
|
|
|
|
def delete_splunk_search(self):
|
|
"""
|
|
Remove the associated splunk shared base search associated with the base search id
|
|
"""
|
|
saved_search_name = self.get_saved_search_name()
|
|
# Delete saved search for kpi
|
|
ret = True
|
|
try:
|
|
ret = SavedSearch.delete_search(self.session_key, saved_search_name)
|
|
except ResourceNotFound:
|
|
logger.exception(
|
|
'Saved search "%s" was not found, ignoring delete',
|
|
saved_search_name
|
|
)
|
|
except Exception:
|
|
logger.exception(
|
|
'Caught exception trying to delete saved search "%s"',
|
|
saved_search_name
|
|
)
|
|
ret = False
|
|
|
|
if not ret:
|
|
logger.error('Failed to delete saved search "%s".', saved_search_name)
|
|
else:
|
|
logger.info('Successfully deleted saved search "%s".', saved_search_name)
|
|
return ret
|
|
|
|
def get_splunk_search(self):
|
|
"""
|
|
Retrieves the splunk search associated with the base_search_id
|
|
"""
|
|
try:
|
|
saved_search_name = self.get_saved_search_name()
|
|
return SavedSearch.get_search(self.session_key, saved_search_name)
|
|
except ResourceNotFound:
|
|
logger.exception("Unable to splunk search: %s", saved_search_name)
|
|
return None
|
|
|
|
##################################################################
|
|
# Search String Generation
|
|
##################################################################
|
|
def generate_shared_base_search_string(self):
|
|
"""
|
|
From the information provided on init,
|
|
construct the shared base search string
|
|
"""
|
|
if self.is_metric:
|
|
# Metric searches are not updated (as of 4.10.x) to support compound aliases
|
|
return self.generate_shared_metric_search_string()
|
|
base_search = self.base_search.get("base_search")
|
|
is_service_entity_filter = normalizeBoolean(self.base_search.get("is_service_entity_filter", False), False)
|
|
search_string = base_search
|
|
if is_service_entity_filter:
|
|
generated_entity_filter_rule = self.update_entity_filter(save_filter=True)
|
|
entity_filter_string = '[ `lookup_shared_base_search_entity_filter_rule({base_search_id})` ' \
|
|
'| return $value ]'.format(base_search_id=self.shared_base_search_id)
|
|
search_string = ItsiSharedAdhocSearch.append_entity_filter(search_string, entity_filter_string)
|
|
logger.debug('Entity filter rule {} was created for base search id {}.'
|
|
.format(generated_entity_filter_rule['_key'], self.shared_base_search_id))
|
|
else:
|
|
efr = ItsiEntityFilterRule(self.session_key, 'nobody')
|
|
efr_key = "EFR-" + self.shared_base_search_id
|
|
entity_filter_rule_object = efr.get('nobody', efr_key)
|
|
if entity_filter_rule_object is not None:
|
|
efr.delete('nobody', efr_key)
|
|
logger.debug("Successfully deleted entity filter rule object : %s", efr_key)
|
|
entity_id_fields = self.base_search.get("entity_id_fields", "")
|
|
self._identify_metrics()
|
|
if self.base_search.get("is_entity_breakdown"):
|
|
entity_breakdown_id_fields = self.base_search.get("entity_breakdown_id_fields", "")
|
|
if isinstance(entity_breakdown_id_fields, str) and ',' in entity_breakdown_id_fields:
|
|
is_compound_alias = True
|
|
compound_alias_search_strings = self.generate_compound_alias_search_strings(entity_breakdown_id_fields)
|
|
else:
|
|
is_compound_alias = False
|
|
compound_alias_search_strings = []
|
|
# ITOA-4442: first, separate metrics with duplicate threshold field and entity statop.
|
|
# so that, we generate correct aggregate entity search.
|
|
search_string += " | " + self.aggregate_raw_into_entity(
|
|
pre_stats_operation=self.diff_entity_filter_breakdown_fields
|
|
)
|
|
# Make sure we don't have any residual data coming through for the results
|
|
search_string += " | eval serviceid=null() "
|
|
search_string += ' | eval sec_grp="' + self.base_search.get('sec_grp') + '"'
|
|
# we would have to perform lookup by entity filter field as well,
|
|
# if entity filter and breakdown fields are different in a base search
|
|
if self.diff_entity_filter_breakdown_fields:
|
|
search_string += ' | `match_filter_entites(' + entity_id_fields + ', sec_grp)`'
|
|
else:
|
|
if is_compound_alias:
|
|
search_string += ' | eval compound_pseudo_entity={compound_entity_title} ' \
|
|
'| `match_compound_entities("{entity_breakdown_ids}", sec_grp, ' \
|
|
'"{identifier_lookups}")`'.format(
|
|
compound_entity_title=compound_alias_search_strings[0],
|
|
entity_breakdown_ids=entity_breakdown_id_fields,
|
|
identifier_lookups=compound_alias_search_strings[2])
|
|
else:
|
|
search_string += ' | `match_entities(' + entity_breakdown_id_fields + ', sec_grp)`'
|
|
|
|
if len(self.services) == 1 and not is_service_entity_filter:
|
|
search_string += ' | eval serviceid="' + '","'.join(list(self.services.keys())) + '"'
|
|
elif len(self.services) > 1 and not is_service_entity_filter: # Redundant, but clarifying
|
|
search_string += ' | eval serviceid=mvappend("' + '","'.join(list(self.services.keys())) + '")'
|
|
search_string += " | mvexpand serviceid"
|
|
if self.diff_entity_filter_breakdown_fields:
|
|
# generate stats command to aggregate entity, after pre stats calculation
|
|
# and filter entity lookup is done above
|
|
search_string += ' | ' + self.aggregate_raw_into_entity()
|
|
search_string += ' | eval sec_grp="' + self.base_search.get('sec_grp') + '"'
|
|
if is_compound_alias:
|
|
search_string += ' | eval compound_pseudo_entity={compound_entity_title}'.format(
|
|
compound_entity_title=compound_alias_search_strings[0])
|
|
search_string += ' | `match_breakdown_compound_alias_entities("{entity_breakdown_id_fields}", ' \
|
|
'sec_grp, "{entity_identifiers}")`'\
|
|
.format(entity_breakdown_id_fields=entity_breakdown_id_fields,
|
|
entity_identifiers=compound_alias_search_strings[2])
|
|
else:
|
|
# This is the default from pre-Robin (4.10.x)
|
|
search_string += ' | `match_breakdown_entities(' + entity_breakdown_id_fields + ', sec_grp)`'
|
|
|
|
search_string += " | " + self.aggregate_entity_into_service()
|
|
else:
|
|
if is_service_entity_filter:
|
|
search_string += ' | eval sec_grp="' + self.base_search.get('sec_grp') + '"'
|
|
search_string += ' | `match_entities(' + entity_id_fields + ', sec_grp)`'
|
|
search_string += ' | mvexpand serviceid'
|
|
search_string += ' | ' + self.aggregate_raw_into_service()
|
|
else:
|
|
# Sacrificing condensed code for readability (I hope)
|
|
search_string += ' | ' + self.aggregate_raw_into_service()
|
|
if len(self.services) == 1:
|
|
search_string += ' | eval serviceid="' + '","'.join(list(self.services.keys())) + '"'
|
|
elif len(self.services) > 1:
|
|
search_string += ' | eval serviceid=mvappend("' + '","'.join(list(self.services.keys())) + '")'
|
|
search_string += ' | mvexpand serviceid'
|
|
|
|
# Now for the common pieces
|
|
alert_period = self.base_search.get('alert_period', '5')
|
|
search_string += ' | `assess_severity(' + self.shared_base_search_id + ')`'
|
|
# Remove any unnecessary fields we dont need
|
|
search_string += ' | ' + self.remove_extraneous_fields()
|
|
# Finish up with a round of evals
|
|
search_string += ' | eval alert_period=' + alert_period + ', itsi_kpi_id=kpiid, itsi_service_id=serviceid'
|
|
return search_string
|
|
|
|
def generate_shared_metric_search_string(self):
|
|
"""
|
|
From the information provided on init,
|
|
construct the shared metric search string
|
|
"""
|
|
|
|
metric = self.base_search.get('metric')
|
|
|
|
base_search = 'WHERE index={metric_index} metric_name="{metric_name}"'\
|
|
.format(metric_index=metric.get('metric_index'), metric_name=metric.get('metric_name'))
|
|
|
|
is_service_entity_filter = normalizeBoolean(self.base_search.get("is_service_entity_filter", False), False)
|
|
search_string = base_search
|
|
sub_search_string = ''
|
|
if is_service_entity_filter:
|
|
generated_entity_filter_rule = self.update_entity_filter(save_filter=True)
|
|
entity_filter_string = '[ `lookup_shared_base_search_entity_filter_rule({base_search_id})` ' \
|
|
'| return $value ]'.format(base_search_id=self.shared_base_search_id)
|
|
logger.debug('Entity filter rule {} was created for base search id {}.'
|
|
.format(generated_entity_filter_rule['_key'], self.shared_base_search_id))
|
|
sub_search_string = ItsiSharedAdhocSearch.append_entity_filter('', entity_filter_string, metric=True)
|
|
|
|
entity_breakdown_id_fields = self.base_search.get("entity_breakdown_id_fields")
|
|
entity_id_fields = self.base_search.get("entity_id_fields")
|
|
self._identify_metrics()
|
|
|
|
if self.base_search.get("is_entity_breakdown"):
|
|
# ITOA-4442: first, separate metrics with duplicate threshold field and entity statop.
|
|
# so that, we generate correct aggregate entity search.
|
|
search_string = self.aggregate_raw_into_entity_metric(
|
|
search_string,
|
|
sub_search_string,
|
|
pre_stats_operation=self.diff_entity_filter_breakdown_fields
|
|
)
|
|
# Make sure we don't have any residual data coming through for the results
|
|
search_string += " | eval serviceid=null() "
|
|
search_string += ' | eval sec_grp="' + self.base_search.get('sec_grp') + '"'
|
|
# we would have to perform lookup by entity filter field as well,
|
|
# if entity filter and breakdown fields are different in a base search
|
|
if self.diff_entity_filter_breakdown_fields:
|
|
# different filter and breakdown fields
|
|
search_string += ' | `match_filter_entites(' + entity_id_fields + ', sec_grp)`'
|
|
else:
|
|
search_string += ' | `match_entities(' + entity_breakdown_id_fields + ', sec_grp)`'
|
|
|
|
if len(self.services) == 1 and not is_service_entity_filter:
|
|
search_string += ' | eval serviceid="' + '","'.join(list(self.services.keys())) + '"'
|
|
elif len(self.services) > 1 and not is_service_entity_filter: # Redundant, but clarifying
|
|
search_string += ' | eval serviceid=mvappend("' + '","'.join(list(self.services.keys())) + '")'
|
|
search_string += " | mvexpand serviceid"
|
|
if self.diff_entity_filter_breakdown_fields:
|
|
# different filter and breakdown fields
|
|
# generate stats command to aggregate entity, after pre stats calculation
|
|
# and filter entity lookup is done above
|
|
search_string += ' | ' + self.aggregate_raw_into_entity()
|
|
search_string += ' | eval sec_grp="' + self.base_search.get('sec_grp') + '"'
|
|
search_string += ' | `match_breakdown_entities(' + entity_breakdown_id_fields + ', sec_grp)`'
|
|
|
|
search_string += ' | ' + self.aggregate_entity_into_service()
|
|
else:
|
|
if is_service_entity_filter:
|
|
search_string += ' by ' + entity_id_fields
|
|
search_string += ' | search' + sub_search_string
|
|
search_string += ' | eval sec_grp="' + self.base_search.get('sec_grp') + '"'
|
|
search_string += ' | `match_entities(' + entity_id_fields + ', sec_grp)`'
|
|
search_string += ' | mvexpand serviceid'
|
|
search_string = self.aggregate_raw_into_service_metric(search_string)
|
|
else:
|
|
# Sacrificing condensed code for readability (I hope)
|
|
# aggregate raw results into service without using serviceid as
|
|
# entity filtering and breakdown is not enabled.
|
|
search_string = self.aggregate_raw_into_service_metric(search_string)
|
|
|
|
# append serviceid after generating aggregate service results
|
|
if len(self.services) == 1:
|
|
search_string += ' | eval serviceid="' + '","'.join(list(self.services.keys())) + '"'
|
|
elif len(self.services) > 1:
|
|
search_string += ' | eval serviceid=mvappend("' + '","'.join(list(self.services.keys())) + '")'
|
|
search_string += ' | mvexpand serviceid'
|
|
|
|
# Now for the common pieces
|
|
alert_period = self.base_search.get("alert_period", '5')
|
|
search_string += ' | `assess_severity(' + self.shared_base_search_id + ')`'
|
|
# Remove any unnecessary fields we dont need
|
|
search_string += " | " + self.remove_extraneous_fields()
|
|
# Finish up with a round of evals
|
|
search_string += ' | eval alert_period=' + alert_period + ', itsi_kpi_id=kpiid, itsi_service_id=serviceid'
|
|
return search_string
|
|
|
|
def expand_combined_fields(self):
|
|
"""
|
|
PER ITOA-4442 we combine the aggregate statistics, however, so that we don't have missing entries,
|
|
we need to re-expand these entities
|
|
@return search string with alias fields in it
|
|
"""
|
|
if len(self.metric_aliases) == 0:
|
|
return ''
|
|
search_strings = []
|
|
for duplicate_metric in self.metric_aliases:
|
|
string = 'alert_value_' + duplicate_metric + '=\'' + 'alert_value_' +\
|
|
self.metric_aliases[duplicate_metric] + '\''
|
|
search_strings.append(string)
|
|
return ' | eval ' + ','.join(search_strings)
|
|
|
|
def remove_extraneous_fields(self):
|
|
"""
|
|
Remove the extraneous fields from the shared base search
|
|
These are the fields based on the metric id fields that weren't copied over into alert_value
|
|
"""
|
|
search_string = 'fields - alert_error'
|
|
if self.diff_entity_filter_breakdown_fields and not self.is_metric:
|
|
search_string += ' entity_filter_fields'
|
|
metrics = self.base_search.get('metrics')
|
|
for metric in metrics:
|
|
metric_id = metric.get('_key')
|
|
search_string += ' alert_value_%s' % metric_id
|
|
return search_string
|
|
|
|
def update_entity_filter(self, save_filter=False):
|
|
"""
|
|
@param save_filter: bool - whether or not we should save the generated filter rule back to kvstore
|
|
|
|
@rtype: dict
|
|
@return: The entity filter rule for the shared base search. Will return nothing in case of misconfiguration
|
|
|
|
Goes through the service information defined by the shared base search initialization and gets entities matched.
|
|
Creates an entity filter rule based on all entities matched by the shared_base_search
|
|
If the param save_filter is TRUE, then we also save this filter rule to the kvstore
|
|
"""
|
|
# First, simple check, make sure that this isn't null and that this is filtering for entities
|
|
if not normalizeBoolean(self.base_search.get("is_service_entity_filter", False), False):
|
|
logger.info("Entity filtering invoked with untrue is_service_entity_filter "
|
|
"- do not generate entity_filter_rule objects.")
|
|
# We are working with a straight up service filter - This is a "normal" working case
|
|
return
|
|
|
|
# Entity Alias Filtering fields are no longer configurable,
|
|
# and instead the Entity Filter Field (AKA the Entity Id fields)
|
|
# is used instead to determine the entity aliases used in search generation
|
|
fieldname = self.base_search.get('entity_id_fields')
|
|
|
|
fieldnames = []
|
|
if isinstance(fieldname, itsi_py3.string_type) and fieldname and fieldname.strip():
|
|
fieldnames = fieldname.split(',')
|
|
|
|
alias_field = self.base_search.get("entity_id_fields")
|
|
entities_in_services = []
|
|
# First, get all of the matching entity filter clauses
|
|
for svc in self.services.values():
|
|
entity_rules = svc.get('entity_rules')
|
|
entities_in_service = ItsiFilter(entity_rules).get_filtered_objects(
|
|
self.session_key, 'nobody', fields=['services:0'])
|
|
entities_in_services.extend(entities_in_service)
|
|
|
|
# Next, extract the appropriate fields and dedup
|
|
entity_alias_info = []
|
|
processed_entities = set()
|
|
for entity in entities_in_services:
|
|
entity_key = entity.get('_key')
|
|
identifiers = entity.get('identifier')
|
|
if not identifiers:
|
|
continue
|
|
|
|
fields = identifiers.get('fields')
|
|
if not isinstance(fields, list):
|
|
continue
|
|
|
|
if len(fieldnames) > 0 and entity_key not in processed_entities:
|
|
processed_entities.add(entity_key)
|
|
for field in fieldnames:
|
|
# Only look at identifying fields to construct the entity filter string
|
|
if field not in fields:
|
|
logger.warning('Entity "{}" does not have identifying field "{}"'
|
|
.format(entity.get('_key'), field))
|
|
continue
|
|
|
|
values = entity.get(field)
|
|
if values is None or len(values) == 0:
|
|
logger.warning("Entity %s had fieldnames specified with no values - skipping"
|
|
% entity.get("_key"))
|
|
continue
|
|
# Using is_valid_name because of the regex protection it affords against un-splunkable aliases
|
|
# If the entity value contains "\", replace it with "\\\". "\" is considered special character
|
|
# Per entity filtering changes in ITSI-2179, we only need the list of of the alias values
|
|
for value in values:
|
|
entity_filter_object = {}
|
|
if utils.is_valid_field_value(value):
|
|
entity_filter_object['alias_value'] = value.replace('\\', '\\\\\\')
|
|
entity_filter_object['entity_key'] = entity_key
|
|
entity_alias_info.append(entity_filter_object)
|
|
# Once we have found all of the matching aliases, create a filter rule and save to the collection
|
|
efr = ItsiEntityFilterRule(self.session_key, 'nobody')
|
|
new_filter_rule = efr.create_shared_base_search_filter(self.shared_base_search_id,
|
|
alias_field,
|
|
entity_alias_info
|
|
)
|
|
# TODO - post ITSI-2179, move the save to kvstore out of function
|
|
if save_filter:
|
|
# If function is called with save_filter, save to entity_filter_rule collection
|
|
# e.g. scenario where we need are creating a new shared base search in savedsearches.conf file
|
|
efr.save_batch('nobody', [new_filter_rule], validate_names=False)
|
|
return new_filter_rule
|
|
|
|
def aggregate_raw_into_service(self, is_aggregate_entity_into_service=False):
|
|
"""
|
|
Generate the aggregate string for all metrics found in the base search
|
|
The macro for this for a single kpi is
|
|
stats $aggregate_statop$($threshold_field$) AS alert_value |
|
|
eval is_service_aggregate="1", is_entity_defined="0",
|
|
entity_key="service_aggregate", entity_title="service_aggregate" |
|
|
`gettime`
|
|
The stats part is what we'll need to change, as the macro definition does not
|
|
allow for a variable number of arguments. Fortunately, just the first piece
|
|
will need to change
|
|
@param is_aggregate_entity_into_service: A parameter to determine whether or not we will need to
|
|
create a by clause for services
|
|
@type is_aggregate_entity_into_service: boolean
|
|
@return: a string representing the search term
|
|
"""
|
|
ret_string = 'stats '
|
|
metrics = self.base_search.get('metrics')
|
|
if not is_aggregate_entity_into_service:
|
|
ret_string += self.add_metrics()
|
|
else:
|
|
for metric in metrics:
|
|
# There is some duplication here between this and add_metrics
|
|
# Which I'm willing to sacrifice for the sake of clarity
|
|
aggregate_statop = metric.get("aggregate_statop")
|
|
threshold_field = metric.get("threshold_field")
|
|
metric_id = metric.get("_key")
|
|
if metric_id is None:
|
|
logger.warning("base_search %s missing a metrics key - skipping" % self.shared_base_search_id)
|
|
continue
|
|
if aggregate_statop is None or threshold_field is None:
|
|
logger.warning("metric %s missing threshold_field or aggregate_statop - skipping"
|
|
% metric.get("_key"))
|
|
continue
|
|
ret_string += "%s(alert_value_%s) AS alert_value_%s " % (aggregate_statop, metric_id, metric_id)
|
|
|
|
# The metric qualifier will change how we choose to aggregate the instances
|
|
metric_qualifier = self.base_search.get('metric_qualifier')
|
|
if isinstance(metric_qualifier, itsi_py3.string_type) and len(metric_qualifier) > 0:
|
|
# We have a metric qualifier, create a slightly different search than before
|
|
ret_string += 'by %s' % metric_qualifier
|
|
|
|
# only group by serviceid for service aggregate results, when entities are involved in calculation.
|
|
if is_aggregate_entity_into_service:
|
|
ret_string += ', serviceid, is_entity_in_maintenance'
|
|
elif self.base_search.get('is_service_entity_filter', False):
|
|
ret_string += ', serviceid'
|
|
else:
|
|
# only group by serviceid for service aggregate results, when entities are involved in calculation.
|
|
if is_aggregate_entity_into_service:
|
|
ret_string += 'by serviceid, is_entity_in_maintenance'
|
|
elif self.base_search.get('is_service_entity_filter', False):
|
|
ret_string += 'by serviceid'
|
|
|
|
if not is_aggregate_entity_into_service:
|
|
ret_string += self.expand_combined_fields()
|
|
ret_string += self._fill_data_gaps()
|
|
ret_string += ' | eval is_entity_in_maintenance="0", is_service_aggregate="1", ' \
|
|
'is_entity_defined="0"'
|
|
# if fill data gaps is enabled for at least one metric,
|
|
# then entity_title would already be set to "service_aggregate".
|
|
if not self.at_least_one_fill_gaps_metric:
|
|
ret_string += ', entity_title="service_aggregate"'
|
|
else:
|
|
# is_entity_in_maintenance field in each of the entity result row is used to determine if all entities in a
|
|
# service is in maintenance or not. In the scenario where entity breakdown and entity filtering are not
|
|
# configured, there will be no entity level results, in that case, we need to eval
|
|
# is_entity_in_maintenance to 0 for the service aggregation row.
|
|
ret_string += ' | sort 0 serviceid is_entity_in_maintenance | dedup consecutive=t serviceid' \
|
|
' | eval is_service_aggregate="1", is_entity_defined="0", entity_title="service_aggregate"'
|
|
|
|
ret_string += ', entity_key="service_aggregate" | `gettime`'
|
|
return ret_string
|
|
|
|
def aggregate_raw_into_service_metric(self, search_string):
|
|
"""
|
|
Generate the aggregate string for all metrics found in the shared metric search
|
|
@return: a string representing the search term
|
|
"""
|
|
# using prestats here since serviceid is not defined when calling mstats
|
|
ret_string = '|mstats '
|
|
|
|
metrics = self.base_search.get("metrics")
|
|
all_aggregate_statop = set()
|
|
metric_aggregate_dict = {}
|
|
for metric in metrics:
|
|
# There is some duplication here between this and aggregate_raw_into_entity
|
|
# Which I'm willing to sacrifice for the sake of clarity
|
|
aggregate_statop = metric.get("aggregate_statop")
|
|
all_aggregate_statop.add(aggregate_statop)
|
|
metric_id = metric.get("_key")
|
|
if metric_id is None:
|
|
logger.warning("base_search %s missing a metrics key - skipping" % self.shared_base_search_id)
|
|
continue
|
|
if aggregate_statop is None:
|
|
logger.warning("metric %s missing aggregate_statop - skipping" % metric.get("_key"))
|
|
continue
|
|
if aggregate_statop in metric_aggregate_dict:
|
|
metric_aggregate_dict[aggregate_statop].append(metric_id)
|
|
else:
|
|
metric_aggregate_dict[aggregate_statop] = [metric_id]
|
|
|
|
if self.base_search.get('is_service_entity_filter'):
|
|
# when entity filtering is enabled, we need to perform mstats prestats
|
|
# and then stats grouped by serviceid, to calculate service aggregate results
|
|
ret_string += 'prestats=t '
|
|
for aggregate_statop in list(all_aggregate_statop):
|
|
ret_string += '%s(_value) ' % (aggregate_statop)
|
|
|
|
ret_string += search_string
|
|
ret_string += ' | stats '
|
|
|
|
eval_string = ''
|
|
for aggregate_statop, metric_ids in list(metric_aggregate_dict.items()):
|
|
ret_string += '%s(_value) AS alert_value_%s ' % (aggregate_statop, metric_ids[0])
|
|
for metric_id in metric_ids[1:]:
|
|
eval_string += ' | eval alert_value_%s=\'alert_value_%s\'' % (metric_id, metric_ids[0])
|
|
|
|
# aggregation by serviceid is only needed, when entity filtering is enabled,
|
|
# to correctly aggregate associated entities for a service
|
|
ret_string += 'by serviceid'
|
|
else:
|
|
# when entity filtering is not enabled, we directly calculate service aggregate
|
|
# using mstats command as we don't need to group by serviceid based on entity
|
|
# service association.
|
|
eval_string = ''
|
|
for aggregate_statop, metric_ids in list(metric_aggregate_dict.items()):
|
|
ret_string += '%s(_value) AS alert_value_%s ' % (aggregate_statop, metric_ids[0])
|
|
for metric_id in metric_ids[1:]:
|
|
eval_string += ' | eval alert_value_%s=\'alert_value_%s\'' % (metric_id, metric_ids[0])
|
|
|
|
ret_string += search_string
|
|
|
|
ret_string += eval_string
|
|
ret_string += self._fill_data_gaps()
|
|
|
|
ret_string += ' | eval is_entity_in_maintenance="0", is_service_aggregate="1", is_entity_defined="0",' \
|
|
' entity_key="service_aggregate"'
|
|
|
|
# if fill data gaps is enabled for at least one metric,
|
|
# then entity_title would already be set to "service_aggregate".
|
|
if not self.at_least_one_fill_gaps_metric:
|
|
ret_string += ', entity_title="service_aggregate"'
|
|
ret_string += ' | `gettime`'
|
|
|
|
return ret_string
|
|
|
|
def add_metrics(self, is_aggregate_into_service=True, pre_stats_operation=False):
|
|
"""
|
|
Add subsearch from metrics
|
|
@type is_aggregate_into_service: bool
|
|
@param is_aggregate_into_service: True, if search string is being generated for service aggregation. else, False
|
|
@param pre_stats_operation: Boolean
|
|
@return: string
|
|
"""
|
|
ret_string = ''
|
|
for metric_id, metric in self.unique_metric_tokens.items():
|
|
aggregate_statop = metric.get('aggregate_statop')
|
|
entity_statop = metric.get('entity_statop')
|
|
threshold_field = metric.get('threshold_field', '_value')
|
|
if self.is_metric:
|
|
threshold_field = '_value'
|
|
if is_aggregate_into_service:
|
|
# search string for aggregate raw into service, when no entities are involved
|
|
ret_string += "%s(%s) AS alert_value_%s " % (aggregate_statop, threshold_field, metric_id)
|
|
else:
|
|
# search string for aggregate raw into entity
|
|
if pre_stats_operation:
|
|
ret_string += '%s(%s) ' % (entity_statop, threshold_field)
|
|
else:
|
|
ret_string += "%s(%s) AS alert_value_%s " % (entity_statop, threshold_field, metric_id)
|
|
return ret_string
|
|
|
|
def aggregate_raw_into_entity(self, pre_stats_operation=False):
|
|
"""
|
|
Generate the aggregate entity for all metrics found in the base search
|
|
The macro this is based on for the single kpi is
|
|
|
|
example:
|
|
STATS = stats $entity_statop$($threshold_field$) AS alert_value by $entity_id_fields$ | `gettime`
|
|
|
|
PRE-STATS = sistats count(log_level) values(component) by sourcetype, component
|
|
|
|
NOTE for sistats command: PBL-5786: allow splitting of base search KPI by a different field than
|
|
entity filter field. To support this, we need to perform pre stats calculation using 'sistats'
|
|
command, since, entity aggregate values depend on multiple fields (breakdown and filter)
|
|
|
|
@param pre_stats_operation: True is want to generate 'sistats` command for prestats calculation
|
|
of entity aggregates
|
|
@type pre_stats_operation: bool
|
|
"""
|
|
ret_string = 'stats '
|
|
if pre_stats_operation:
|
|
ret_string = 'sistats '
|
|
# TODO: Get clarification on whether or not this should be a string, a list
|
|
# And handle appropriately. From my understanding, it needs to be a single field
|
|
# And should PROBABLY be renamed to the singular
|
|
entity_breakdown_id_fields = self.base_search.get("entity_breakdown_id_fields")
|
|
entity_id_fields = self.base_search.get("entity_id_fields")
|
|
if not utils.is_valid_str(entity_breakdown_id_fields):
|
|
raise Exception(_("For an entity search, valid entity breakdown id field is required."))
|
|
if not pre_stats_operation and self.diff_entity_filter_breakdown_fields\
|
|
and not utils.is_valid_str(entity_id_fields):
|
|
raise Exception(_("For an entity search, valid entity id field is required."))
|
|
|
|
ret_string += self.add_metrics(is_aggregate_into_service=False, pre_stats_operation=pre_stats_operation)
|
|
|
|
# Now add the suffix and be on our way. The metric qualifier will
|
|
# change how we choose to aggregate the instances
|
|
metric_qualifier = self.base_search.get("metric_qualifier")
|
|
has_metric_qualifier = utils.is_valid_str(metric_qualifier)
|
|
|
|
# We have a metric qualifier, create a slightly different search than before
|
|
if has_metric_qualifier:
|
|
if pre_stats_operation:
|
|
ret_string += 'values(%s) by %s, %s, %s' % \
|
|
(entity_id_fields, entity_breakdown_id_fields, entity_id_fields, metric_qualifier)
|
|
else:
|
|
if self.diff_entity_filter_breakdown_fields:
|
|
if self.is_metric:
|
|
ret_string += 'by %s, serviceid, %s' % (entity_breakdown_id_fields, metric_qualifier)
|
|
else:
|
|
ret_string += 'values(%s) as entity_filter_fields by %s, serviceid, %s' % \
|
|
(entity_id_fields, entity_breakdown_id_fields, metric_qualifier)
|
|
|
|
else:
|
|
ret_string += 'by %s, %s' % (entity_breakdown_id_fields, metric_qualifier)
|
|
else:
|
|
if pre_stats_operation:
|
|
if ',' in entity_breakdown_id_fields:
|
|
entity_aggregation_fields = entity_breakdown_id_fields.split(',')
|
|
if entity_id_fields not in entity_aggregation_fields:
|
|
entity_aggregation_fields.append(entity_id_fields)
|
|
else:
|
|
entity_aggregation_fields = [entity_breakdown_id_fields, entity_id_fields]
|
|
split_by_fields = ','.join(entity_aggregation_fields)
|
|
ret_string += 'values(%s) by %s' % \
|
|
(entity_id_fields, split_by_fields)
|
|
else:
|
|
if self.diff_entity_filter_breakdown_fields:
|
|
if self.is_metric:
|
|
ret_string += 'by %s, serviceid' % entity_breakdown_id_fields
|
|
else:
|
|
ret_string += 'values(%s) as entity_filter_fields by %s, serviceid' % \
|
|
(entity_id_fields, entity_breakdown_id_fields)
|
|
else:
|
|
ret_string += 'by %s' % entity_breakdown_id_fields
|
|
|
|
if not pre_stats_operation:
|
|
ret_string += self.expand_combined_fields()
|
|
ret_string += self._fill_data_gaps()
|
|
ret_string += ' | `gettime`'
|
|
|
|
return ret_string
|
|
|
|
def aggregate_raw_into_entity_metric(self, search_string, sub_search_string=None, pre_stats_operation=False):
|
|
"""
|
|
Generate the aggregate entity for all metrics found in the base search
|
|
The macro this is based on for the single kpi is
|
|
|
|
@param pre_stats_operation: True is want to generate 'prestats` command for prestats calculation
|
|
of entity aggregates
|
|
@type pre_stats_operation: bool
|
|
"""
|
|
# a new method for metric based KPI
|
|
ret_string = '| mstats '
|
|
if pre_stats_operation:
|
|
ret_string = '| mstats prestats=t '
|
|
# And handle appropriately. From my understanding, it needs to be a single field
|
|
# And should PROBABLY be renamed to the singular
|
|
entity_breakdown_id_fields = self.base_search.get("entity_breakdown_id_fields")
|
|
entity_id_fields = self.base_search.get("entity_id_fields")
|
|
if not utils.is_valid_str(entity_breakdown_id_fields):
|
|
raise Exception(_("For an entity search, valid entity breakdown id field is required."))
|
|
if not pre_stats_operation and self.diff_entity_filter_breakdown_fields\
|
|
and not utils.is_valid_str(entity_id_fields):
|
|
raise Exception(_("For an entity search, valid entity id field is required."))
|
|
|
|
ret_string += self.add_metrics(is_aggregate_into_service=False, pre_stats_operation=pre_stats_operation)
|
|
|
|
ret_string += search_string
|
|
|
|
if sub_search_string:
|
|
# apply entity filter
|
|
if pre_stats_operation:
|
|
ret_string += ' by %s, %s | search%s'\
|
|
% (entity_breakdown_id_fields, entity_id_fields, sub_search_string)
|
|
else:
|
|
ret_string += ' by %s | search%s' % (entity_breakdown_id_fields, sub_search_string)
|
|
else:
|
|
if pre_stats_operation:
|
|
# different filter and breakdown fields
|
|
ret_string += ' by %s, %s' % (entity_breakdown_id_fields, entity_id_fields)
|
|
else:
|
|
ret_string += ' by %s' % entity_breakdown_id_fields
|
|
|
|
if not pre_stats_operation:
|
|
ret_string += self.expand_combined_fields()
|
|
ret_string += self._fill_data_gaps()
|
|
ret_string += ' | `gettime`'
|
|
|
|
return ret_string
|
|
|
|
def aggregate_entity_into_service(self):
|
|
"""
|
|
Generate the service aggregate based on the existing single value macro
|
|
definition:
|
|
appendpipe [stats $service_statop$(alert_value) AS alert_value |
|
|
eval is_service_aggregate="1", is_entity_defined="0",
|
|
entity_key="service_aggregate", entity_title="service_aggregate"] | `gettime`
|
|
|
|
You'll notice that this is really really similar to the aggregate_raw_into_entity_command but using an
|
|
appendpipe.
|
|
"""
|
|
return "appendpipe [" + self.aggregate_raw_into_service(is_aggregate_entity_into_service=True) + "]"
|
|
|
|
def gen_preview_alert_search(self, kpi):
|
|
"""
|
|
Generate the preview alert search
|
|
It basically means generating a normal search string, but with the kpi adjusted
|
|
@param kpi: A kpi dictionary representing the new thing to be edited and previewed
|
|
@type kpi: dict
|
|
"""
|
|
service_id = kpi.get("service_id", "NEW_SERVICE_ID")
|
|
if service_id not in self.services:
|
|
# Add the service to the list
|
|
service = self.backend.get(self.session_key, 'nobody', 'service', service_id)
|
|
if service is not None:
|
|
self.services[service_id] = service
|
|
else:
|
|
logger.error("No matching services found for service_id=%s", service_id)
|
|
return None
|
|
found = False
|
|
for idx, old_kpi in enumerate(self.services[service_id]['kpis']):
|
|
if kpi.get("_key") == old_kpi.get("_key"):
|
|
# Replace the kpi
|
|
self.services[service_id]['kpis'][idx] = kpi
|
|
found = True
|
|
break
|
|
if not found:
|
|
# We're dealing with a new kpi
|
|
self.services[service_id]['kpis'].append(kpi)
|
|
result = self.generate_shared_base_search_string()
|
|
return result
|
|
|
|
def _identify_metrics(self):
|
|
"""
|
|
Goes through the list of metrics, stores unique metrics in a global
|
|
variable and stores duplicate metrics in another global variable with
|
|
appropriate aliases for metric alert_value.
|
|
@return: None
|
|
"""
|
|
duplicate_metric_tokens = {}
|
|
metric_token_keys = {}
|
|
metrics = self.base_search.get('metrics', [])
|
|
for metric in metrics:
|
|
entity_statop = metric.get('entity_statop')
|
|
aggregate_statop = metric.get('aggregate_statop')
|
|
threshold_field = metric.get('threshold_field')
|
|
metric_id = metric.get('_key')
|
|
if metric_id is None:
|
|
logger.warning("kpi_base_search=%s missing a metrics key - skipping" % self.shared_base_search_id)
|
|
continue
|
|
if threshold_field is None or (not self.base_search.get('is_entity_breakdown')
|
|
and aggregate_statop is None) or entity_statop is None:
|
|
logger.warning("metric %s missing threshold_field or entity_statop - skipping" % metric.get("_key"))
|
|
continue
|
|
|
|
if not self.at_least_one_fill_gaps_metric and metric.get('fill_gaps') == 'last_available_value':
|
|
self.at_least_one_fill_gaps_metric = True
|
|
|
|
# For ITOA-4442, we should check and see if the "token" tuple of entity_statop, threshold_field are unique
|
|
if self.base_search.get('is_entity_breakdown'):
|
|
identifying_tuple = (entity_statop, threshold_field)
|
|
else:
|
|
identifying_tuple = (aggregate_statop, threshold_field)
|
|
|
|
if identifying_tuple not in metric_token_keys:
|
|
metric_token_keys[identifying_tuple] = metric_id
|
|
self.unique_metric_tokens[metric_id] = {
|
|
'entity_statop': entity_statop,
|
|
'aggregate_statop': aggregate_statop,
|
|
'threshold_field': threshold_field
|
|
}
|
|
# ITOA-4442 - update the duplicate statop fields, they won't be dicts like the key field
|
|
else:
|
|
if identifying_tuple not in duplicate_metric_tokens:
|
|
duplicate_metric_tokens[identifying_tuple] = []
|
|
duplicate_metric_tokens[identifying_tuple].append(metric_id)
|
|
|
|
# ITOA-4442: Now we iterate through the metric ids, checking for duplicates
|
|
for tuple_key, metric_key in metric_token_keys.items():
|
|
if tuple_key not in duplicate_metric_tokens:
|
|
continue
|
|
duplicate_metric_ids = duplicate_metric_tokens[tuple_key]
|
|
for duplicate_metric in duplicate_metric_ids:
|
|
self.metric_aliases[duplicate_metric] = metric_key
|
|
|
|
def _transpose_aggregated_metric_values(self, metrics):
|
|
"""
|
|
Transpose the metric results such that each row of results contains
|
|
metric id and corresponding alert value.
|
|
This is needed to perform lookup of cached metric alert values, based on
|
|
metric id and base search id.
|
|
@type metrics: list of dict
|
|
@param metrics: list of metrics objects defined in shared base search.
|
|
@rtype: basestring
|
|
@return: search string
|
|
"""
|
|
ret_string = ''
|
|
metric_id_strings = []
|
|
metric_alert_value_strings = []
|
|
for metric in metrics:
|
|
metric_id = metric.get('_key')
|
|
metric_id_strings.append('%s' % metric_id)
|
|
metric_alert_value_strings.append('alert_value_%s' % metric_id)
|
|
|
|
if len(metrics) == 1:
|
|
ret_string += ' | eval metric_id="' + metric_id_strings[0] + '"'
|
|
else:
|
|
ret_string += ' | eval metric_id=mvappend("' + '", "'.join(metric_id_strings) + '")'
|
|
ret_string += ' | mvexpand metric_id'
|
|
|
|
case_cmd_args = []
|
|
for metric in metrics:
|
|
metric_id = metric.get('_key')
|
|
case_arg = 'metric_id=="%s", \'alert_value_%s\'' % (metric_id, metric_id)
|
|
case_cmd_args.append(case_arg)
|
|
|
|
ret_string += ' | eval alert_value=case(' + ', '.join(case_cmd_args) + ')'
|
|
ret_string += ' | fields - ' + ', '.join(metric_alert_value_strings)
|
|
if self.diff_entity_filter_breakdown_fields:
|
|
ret_string += ', entity_filter_fields'
|
|
|
|
return ret_string
|
|
|
|
def _lookup_cached_metric_results(self, metrics,
|
|
entity_split_field,
|
|
is_compound_alias,
|
|
compound_alias_search_strings):
|
|
"""
|
|
Generate search to lookup cached metric aggregate/entity alert value(s)
|
|
for the metrics with "fill_gaps" attribute set to "last_available_value".
|
|
|
|
@type metrics: list of dict
|
|
@param metrics: list of metrics objects defined in shared base search.
|
|
@type entity_split_field: string
|
|
@param entity_split_field: a string representing the alias(es) (can be more than one in case of
|
|
compound entity aliases) for splitting KPI calculation
|
|
@type is_compound_alias: boolean
|
|
@param is_compound_alias: whether the base search is split-by a compound entity alias
|
|
@type compound_alias_search_strings: list of basestrings
|
|
@param compound_alias_search_strings: a list of various search strings that are used in
|
|
compound alias searches. The one used in this function is a string representing the
|
|
alias(es) (can be more than one in case of compound entity aliases) concatenated together
|
|
by a colon
|
|
@rtype: basestring
|
|
@return: search string
|
|
"""
|
|
ret_string = ''
|
|
ret_string += ' | inputlookup append=true itsi_kpi_alert_value_cache where (base_search_id="%s"' \
|
|
% self.shared_base_search_id
|
|
|
|
if self.diff_entity_filter_breakdown_fields:
|
|
ret_string += ' AND entity_split_field="%s" AND entity_title!="service_aggregate" AND serviceid=*' \
|
|
% entity_split_field
|
|
elif self.base_search.get('is_entity_breakdown'):
|
|
ret_string += ' AND entity_split_field="%s" AND entity_title!="service_aggregate"' \
|
|
% entity_split_field
|
|
elif self.base_search.get('is_service_entity_filter'):
|
|
ret_string += ' AND serviceid=* AND entity_title="service_aggregate"'
|
|
else:
|
|
ret_string += ' AND entity_title="service_aggregate"'
|
|
|
|
or_clause = []
|
|
for metric in metrics:
|
|
# only lookup for the cached results of metrics for which "fill gaps"
|
|
# option is set to "last available value"
|
|
if metric.get('fill_gaps') == 'last_available_value':
|
|
metric_id = metric.get('_key')
|
|
or_clause.append('metric_id="%s"' % metric_id)
|
|
ret_string += ' AND (' + ' OR '.join(or_clause) + '))'
|
|
|
|
return ret_string
|
|
|
|
def _fill_metric_data_gaps_and_update_cache(
|
|
self,
|
|
entity_split_field,
|
|
is_compound_alias,
|
|
compound_alias_search_strings):
|
|
"""
|
|
Generate search to fill entity/aggregate data gaps for metrics,
|
|
with cached metric alert values.
|
|
Also, update the cache (itsi_kpi_alert_value_cache lookup) with new
|
|
alert values along with mod_time, if new alert value for
|
|
metric is encountered.
|
|
@type entity_split_field: string
|
|
@param entity_split_field: a string representing the alias(es) (can be more than one in case of
|
|
compound entity aliases) for splitting KPI calculation
|
|
@type is_compound_alias: boolean
|
|
@param is_compound_alias: whether the base search is split-by a compound entity alias
|
|
@type compound_alias_search_strings: list of basestrings
|
|
@param compound_alias_search_strings: a list of various search strings that are used in
|
|
compound alias searches. The one used in this function is a search string splitting
|
|
the entity_title then coaslescing the fields to the entity alias fields
|
|
@rtype: basestring
|
|
@return: search string
|
|
"""
|
|
ret_string = ' | eval mod_time=if(isnotnull(alert_value), now(), mod_time)'
|
|
if self.diff_entity_filter_breakdown_fields or self.base_search.get('is_entity_breakdown'):
|
|
if is_compound_alias:
|
|
ret_string += (compound_alias_search_strings[1])
|
|
else:
|
|
ret_string += ' | eval entity_title=coalesce(%s, entity_title)' % entity_split_field
|
|
|
|
if self.base_search.get('is_entity_breakdown'):
|
|
if is_compound_alias:
|
|
# When it's a compound alias, we aggregate BY the split field NOT the entity title
|
|
# since the entity title will be using a different format (field1:field2:field3...)
|
|
ret_string += ' | stats first(*) as * max(mod_time) as mod_time by metric_id, %s' \
|
|
% entity_split_field
|
|
else:
|
|
ret_string += ' | stats first(*) as * max(mod_time) as mod_time by metric_id, entity_title'
|
|
|
|
# If we are also filtering but it differs, then we also need to add serviceid to BY clause
|
|
if self.diff_entity_filter_breakdown_fields:
|
|
ret_string += ', serviceid'
|
|
|
|
ret_string += ' | eval cached_alert_value=coalesce(alert_value, cached_alert_value), base_search_id="%s"' \
|
|
% self.shared_base_search_id
|
|
|
|
if is_compound_alias:
|
|
# For compound aliases, need to work created compound_alias field
|
|
ret_string += ' | eval entity_title=coalesce(entity_title, %s)' % compound_alias_search_strings[0]
|
|
|
|
ret_string += ' | fields - alert_value, %s' \
|
|
' | eval entity_split_field="%s", metric_lookup_key=metric_id."_".entity_title' \
|
|
% (entity_split_field, entity_split_field)
|
|
|
|
if self.diff_entity_filter_breakdown_fields:
|
|
ret_string += '."_".serviceid'
|
|
|
|
elif self.base_search.get('is_service_entity_filter'):
|
|
ret_string += ' | stats first(*) as * max(mod_time) as mod_time by metric_id, serviceid' \
|
|
' | eval cached_alert_value=coalesce(alert_value, cached_alert_value),' \
|
|
' base_search_id="%s", entity_title="service_aggregate"' \
|
|
' | fields - alert_value' \
|
|
' | eval metric_lookup_key=metric_id."_".serviceid' \
|
|
% self.shared_base_search_id
|
|
else:
|
|
ret_string += ' | stats first(*) as * max(mod_time) as mod_time by metric_id' \
|
|
' | eval cached_alert_value=coalesce(alert_value, cached_alert_value),' \
|
|
' base_search_id="%s", entity_title="service_aggregate"' \
|
|
' | fields - alert_value' \
|
|
' | eval metric_lookup_key=metric_id."_".entity_title' \
|
|
% self.shared_base_search_id
|
|
|
|
ret_string += ', mod_time=if(isnull(mod_time), now(), mod_time)' \
|
|
' | outputlookup itsi_kpi_alert_value_cache append=true key_field=metric_lookup_key'
|
|
return ret_string
|
|
|
|
def _convert_transposed_results_to_original_form(self, metrics,
|
|
entity_split_field,
|
|
is_compound_alias,
|
|
compound_alias_search_strings):
|
|
"""
|
|
Convert transposed results back to original form, after filling
|
|
data gaps for metrics defined in shared base search.
|
|
|
|
@type metrics: list of dict
|
|
@param metrics: list of metrics objects defined in shared base search.
|
|
@type entity_split_field: string
|
|
@param entity_split_field: a string representing the alias(es) (can be more than one in case of
|
|
compound entity aliases) for splitting KPI calculation
|
|
@type is_compound_alias: boolean
|
|
@param is_compound_alias: whether the base search is split-by a compound entity alias
|
|
@type compound_alias_search_strings: list of basestrings
|
|
@param compound_alias_search_strings: a list of various search strings that are used in
|
|
compound alias searches. The one used in this function is a string representing the
|
|
alias(es) (can be more than one in case of compound entity aliases) concatenated together
|
|
by a colon
|
|
@rtype: basestring
|
|
@return: search string
|
|
"""
|
|
ret_string = ''
|
|
eval_metric_alert_value = []
|
|
for metric in metrics:
|
|
metric_id = metric.get('_key')
|
|
eval_metric = 'alert_value_%s=if(metric_id=="%s", cached_alert_value, null())' % (metric_id, metric_id)
|
|
eval_metric_alert_value.append(eval_metric)
|
|
ret_string += ' | eval ' + ', '.join(eval_metric_alert_value)
|
|
ret_string += ' | fields - metric_id, cached_alert_value, base_search_id, metric_lookup_key'
|
|
|
|
if self.base_search.get('is_entity_breakdown'):
|
|
if is_compound_alias:
|
|
ret_string += ', entity_split_field ' + compound_alias_search_strings[1]
|
|
else:
|
|
ret_string += ', entity_split_field | rename entity_title as %s' % (entity_split_field)
|
|
|
|
ret_string += ' | stats first(*) as * by %s' % (entity_split_field)
|
|
if self.diff_entity_filter_breakdown_fields:
|
|
ret_string += ', serviceid'
|
|
elif self.base_search.get('is_service_entity_filter'):
|
|
ret_string += ' | stats first(*) as * by serviceid'
|
|
else:
|
|
ret_string += ' | stats first(*) as *'
|
|
|
|
return ret_string
|
|
|
|
def _fill_data_gaps(self):
|
|
"""
|
|
Generate the search part to fill data gaps with last available value for metrics,
|
|
if "fill_gaps" attribute is set to "last_available_value" for metric.
|
|
|
|
@rtype: basestring
|
|
@return: search string which perform fill data gaps operation
|
|
"""
|
|
ret_string = ''
|
|
metrics = self.base_search.get('metrics', [])
|
|
if not self.at_least_one_fill_gaps_metric or len(metrics) == 0:
|
|
return ret_string
|
|
|
|
entity_split_field = self.base_search.get('entity_breakdown_id_fields')
|
|
is_compound_alias = False
|
|
compound_alias_search_strings = []
|
|
if ',' in entity_split_field:
|
|
is_compound_alias = True
|
|
compound_alias_search_strings = self.generate_compound_alias_search_strings(entity_split_field)
|
|
|
|
ret_string += self._transpose_aggregated_metric_values(metrics)
|
|
ret_string += self._lookup_cached_metric_results(metrics, entity_split_field, is_compound_alias,
|
|
compound_alias_search_strings)
|
|
ret_string += self._fill_metric_data_gaps_and_update_cache(entity_split_field, is_compound_alias,
|
|
compound_alias_search_strings)
|
|
ret_string += self._convert_transposed_results_to_original_form(metrics, entity_split_field, is_compound_alias,
|
|
compound_alias_search_strings)
|
|
|
|
return ret_string
|
|
|
|
@staticmethod
|
|
def generate_compound_alias_search_strings(entity_breakdown_id_fields):
|
|
"""
|
|
Helper function that returns an array of search strings that are used across
|
|
the various fill data gaps functions.
|
|
|
|
@type entity_breakdown_id_fields: basestring
|
|
@param entity_breakdown_id_fields: the entity_breakdown_id_fields, which should be comma separated
|
|
@rtype: list of strings
|
|
@return: a list of strings ->
|
|
[concatenated_breakdown_id_strings, breakdown_fields_from_entity_title, #compound_entity_lookups]
|
|
"""
|
|
entity_aliases = [alias.strip() for alias in entity_breakdown_id_fields.split(',')]
|
|
itsi_identifier_lookup_list = []
|
|
for alias in entity_aliases:
|
|
itsi_identifier_lookup_list.append('_itsi_identifier_lookups AS itsi_identifier_' + alias)
|
|
compound_itsi_identifier_lookups = ', '.join(itsi_identifier_lookup_list)
|
|
# We are opting to go with :: as the delimiter of choice when displaying the values of a pseudoentity
|
|
concatenated_breakdown_id_fields = '."::".'.join(entity_aliases)
|
|
coalesced_breakdown_fields = []
|
|
# When we fill data gaps, we need to coalesce and fill in values from the cached results
|
|
# which will come from the saved entity_title
|
|
for ind in range(len(entity_aliases)):
|
|
coalesced_breakdown_fields.append('{entity_alias}=coalesce({entity_alias}, mvindex(entity_title, '
|
|
'{index}))'.format(entity_alias=entity_aliases[ind], index=ind))
|
|
breakdown_fields_from_entity_title = ' | eval entity_title=split(entity_title, "::")' \
|
|
' | eval {coalesced_breakdown_fields}'\
|
|
' | fields - entity_title'.format(
|
|
coalesced_breakdown_fields=', '.join(coalesced_breakdown_fields))
|
|
|
|
return [concatenated_breakdown_id_fields, breakdown_fields_from_entity_title, compound_itsi_identifier_lookups]
|