From 52e7b91e627ce073508197d244c047c43f011a8f Mon Sep 17 00:00:00 2001 From: PagesCoffy Date: Thu, 12 Sep 2024 11:05:25 +0000 Subject: [PATCH] [Integration][OpsGenie] - Performance Improvement (#967) --- .../opsgenie/.port/resources/blueprints.json | 132 ++++++++++++---- .../.port/resources/port-app-config.yaml | 59 +++++++- integrations/opsgenie/.port/spec.yaml | 3 + integrations/opsgenie/CHANGELOG.md | 16 ++ integrations/opsgenie/client.py | 94 ++---------- integrations/opsgenie/integration.py | 108 +++++++++++++ integrations/opsgenie/main.py | 142 ++++++++++-------- integrations/opsgenie/pyproject.toml | 2 +- integrations/opsgenie/utils.py | 9 ++ 9 files changed, 383 insertions(+), 182 deletions(-) create mode 100644 integrations/opsgenie/integration.py diff --git a/integrations/opsgenie/.port/resources/blueprints.json b/integrations/opsgenie/.port/resources/blueprints.json index 15304e744a..12962fb18d 100644 --- a/integrations/opsgenie/.port/resources/blueprints.json +++ b/integrations/opsgenie/.port/resources/blueprints.json @@ -1,8 +1,8 @@ [ { - "identifier": "opsGenieService", - "description": "This blueprint represents an OpsGenie service in our software catalog", - "title": "OpsGenie Service", + "identifier": "opsGenieTeam", + "description": "This blueprint represents an OpsGenie team in our software catalog", + "title": "OpsGenie Team", "icon": "OpsGenie", "schema": { "properties": { @@ -18,53 +18,112 @@ "format": "url", "icon": "DefaultProperty" }, - "tags": { + "oncallUsers": { "type": "array", + "title": "Current Oncalls", "items": { - "type": "string" - }, - "title": "Tags", - "icon": "DefaultProperty" + "type": "string", + "format": "user" + } + } + }, + "required": [] + }, + "mirrorProperties": {}, + "calculationProperties": {}, + "aggregationProperties": {}, + "relations": {} + }, + { + "identifier": "opsGenieSchedule", + "description": "This blueprint represents a OpsGenie schedule in our software catalog", + "title": "OpsGenie Schedule", + "icon": "OpsGenie", + "schema": { + "properties": { + "timezone": { + "title": "Timezone", + "type": "string" }, - "oncallTeam": { - "type": "string", - "title": "OnCall Team", - "description": "Name of the team responsible for this service", - "icon": "DefaultProperty" + "description": { + "title": "Description", + "type": "string" }, - "teamMembers": { - "icon": "TwoUsers", + "users": { + "title": "Users", "type": "array", "items": { "type": "string", "format": "user" - }, - "title": "Team Members", - "description": "Members of team responsible for this service" + } }, - "oncallUsers": { - "icon": "TwoUsers", + "startDate": { + "title": "Start Date", + "type": "string", + "format": "date-time" + }, + "endDate": { + "title": "End Date", + "type": "string", + "format": "date-time" + }, + "rotationType": { + "type": "string", + "title": "Rotation Type" + } + }, + "required": [] + }, + "mirrorProperties": {}, + "calculationProperties": {}, + "aggregationProperties": {}, + "relations": { + "ownerTeam": { + "title": "Owner Team", + "target": "opsGenieTeam", + "required": false, + "many": false + } + } + }, + { + "identifier": "opsGenieService", + "description": "This blueprint represents an OpsGenie service in our software catalog", + "title": "OpsGenie Service", + "icon": "OpsGenie", + "schema": { + "properties": { + "description": { + "type": "string", + "title": "Description", + "icon": "DefaultProperty" + }, + "url": { + "title": "URL", + "type": "string", + "description": "URL to the service", + "format": "url", + "icon": "DefaultProperty" + }, + "tags": { "type": "array", "items": { - "type": "string", - "format": "user" + "type": "string" }, - "title": "Oncall Users", - "description": "Who is on call for this service" + "title": "Tags", + "icon": "DefaultProperty" } }, "required": [] }, - "mirrorProperties": {}, - "calculationProperties": { - "teamSize": { - "title": "Team Size", - "icon": "DefaultProperty", - "description": "Size of the team", - "calculation": ".properties.teamMembers | length", - "type": "number" + "mirrorProperties": { + "oncallUsers": { + "title": "Current Oncalls", + "path": "ownerTeam.oncallUsers" } }, + "calculationProperties": { + }, "aggregationProperties": { "numberOfOpenIncidents": { "title": "Number of open incidents", @@ -86,7 +145,14 @@ } } }, - "relations": {} + "relations": { + "ownerTeam": { + "title": "Owner Team", + "target": "opsGenieTeam", + "required": false, + "many": false + } + } }, { "identifier": "opsGenieIncident", diff --git a/integrations/opsgenie/.port/resources/port-app-config.yaml b/integrations/opsgenie/.port/resources/port-app-config.yaml index 3181cc2b26..9c5e27a361 100644 --- a/integrations/opsgenie/.port/resources/port-app-config.yaml +++ b/integrations/opsgenie/.port/resources/port-app-config.yaml @@ -1,25 +1,59 @@ createMissingRelatedEntities: true deleteDependentEntities: true resources: + - kind: team + selector: + query: 'true' + port: + entity: + mappings: + identifier: .id + title: .name + blueprint: '"opsGenieTeam"' + properties: + description: .description + url: .links.web + - kind: schedule + selector: + query: 'true' + apiQueryParams: + expand: rotation + port: + itemsToParse: .rotations + entity: + mappings: + identifier: .id + "_" + .item.id + title: .name + "_" + .item.name + blueprint: '"opsGenieSchedule"' + properties: + timezone: .timezone + description: .description + startDate: .item.startDate + endDate: .item.endDate + rotationType: .item.type + users: '[.item.participants[] | select(has("username")) | .username]' + relations: + ownerTeam: .ownerTeam.id - kind: service selector: query: 'true' port: entity: mappings: - identifier: .name | gsub("[^a-zA-Z0-9@_.:/=-]"; "-") | tostring + identifier: .id title: .name blueprint: '"opsGenieService"' properties: description: .description url: .links.web tags: .tags - oncallTeam: .__team.name - teamMembers: '[.__team.members[].user.username]' - oncallUsers: .__oncalls.onCallRecipients + relations: + ownerTeam: .teamId - kind: alert selector: query: 'true' + apiQueryParams: + status: open port: entity: mappings: @@ -40,10 +74,12 @@ resources: description: .description integration: .integration.name relations: - relatedIncident: .__relatedIncident.id + relatedIncident: 'if (.alias | contains("_")) then (.alias | split("_")[0]) else null end' - kind: incident selector: query: 'true' + apiQueryParams: + status: open port: entity: mappings: @@ -60,4 +96,15 @@ resources: updatedAt: .updatedAt description: .description relations: - services: '[.__impactedServices[] | .name | gsub("[^a-zA-Z0-9@_.:/=-]"; "-") | tostring]' + services: .impactedServices + - kind: schedule-oncall + selector: + query: 'true' + port: + entity: + mappings: + identifier: .ownerTeam.id + title: .ownerTeam.name + blueprint: '"opsGenieTeam"' + properties: + oncallUsers: .__currentOncalls.onCallRecipients diff --git a/integrations/opsgenie/.port/spec.yaml b/integrations/opsgenie/.port/spec.yaml index 80a7049943..70fa7d5873 100644 --- a/integrations/opsgenie/.port/spec.yaml +++ b/integrations/opsgenie/.port/spec.yaml @@ -8,6 +8,9 @@ features: - kind: alert - kind: service - kind: incident + - kind: schedule + - kind: team + - kind: schedule-on-call configurations: - name: apiToken required: true diff --git a/integrations/opsgenie/CHANGELOG.md b/integrations/opsgenie/CHANGELOG.md index 0e3f2cf497..ee454b173a 100644 --- a/integrations/opsgenie/CHANGELOG.md +++ b/integrations/opsgenie/CHANGELOG.md @@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +## 0.2.0 (2024-09-09) + + +### Improvements + +- Added new kinds for `team`, `schedule` and `schedule-oncall` +- Added support for filtering data from OpsGenie API to fetch only required data +- Introduced logs to facilitate easier debugging of integration issues + +### Breaking Changes + +- Removed extra API calls for fetching impacted services, improving performance with existing incident-service relations +- Removed extra API calls for relating alerts to incidents, using JQ for better performance and accuracy +- Changed `OpsGenieService` blueprint by removing team properties and making it a relation to team blueprint + + ## 0.1.77 (2024-09-05) diff --git a/integrations/opsgenie/client.py b/integrations/opsgenie/client.py index 95a6fab926..f71e6d9a03 100644 --- a/integrations/opsgenie/client.py +++ b/integrations/opsgenie/client.py @@ -3,8 +3,8 @@ import httpx from loguru import logger -from port_ocean.context.event import event from port_ocean.utils import http_async_client +from port_ocean.utils.cache import cache_iterator_result from utils import ObjectKind, RESOURCE_API_VERSIONS PAGE_SIZE = 100 @@ -40,26 +40,23 @@ async def _get_single_resource( ) raise + @cache_iterator_result() async def get_paginated_resources( - self, resource_type: ObjectKind + self, resource_type: ObjectKind, query_params: Optional[dict[str, Any]] = None ) -> AsyncGenerator[list[dict[str, Any]], None]: - cache_key = resource_type.value - - if cache := event.attributes.get(cache_key): - yield cache - return api_version = await self.get_resource_api_version(resource_type) url = f"{self.api_url}/{api_version}/{resource_type.value}s" - pagination_params: dict[str, Any] = {"limit": PAGE_SIZE} - resources_list = [] + + pagination_params: dict[str, Any] = {"limit": PAGE_SIZE, **(query_params or {})} while url: try: + logger.info( + f"Fetching data from {url} with query params {pagination_params}" + ) response = await self._get_single_resource( url=url, query_params=pagination_params ) - batch_data = response["data"] - resources_list.extend(batch_data) - yield batch_data + yield response["data"] url = response.get("paging", {}).get("next") except httpx.HTTPStatusError as e: @@ -67,82 +64,17 @@ async def get_paginated_resources( f"HTTP error with status code: {e.response.status_code} and response text: {e.response.text}" ) raise - event.attributes[cache_key] = resources_list async def get_alert(self, identifier: str) -> dict[str, Any]: + logger.debug(f"Fetching alert {identifier}") api_version = await self.get_resource_api_version(ObjectKind.ALERT) url = f"{self.api_url}/{api_version}/alerts/{identifier}" alert_data = (await self._get_single_resource(url))["data"] - return await self.get_related_incident_by_alert(alert_data) + return alert_data - async def get_oncall_team(self, identifier: str) -> dict[str, Any]: - cache_key = f"{ObjectKind.TEAM}-{identifier}" - if cache := event.attributes.get(cache_key): - return cache - api_version = await self.get_resource_api_version(ObjectKind.TEAM) - url = f"{self.api_url}/{api_version}/teams/{identifier}" - oncall_team = (await self._get_single_resource(url))["data"] - event.attributes[cache_key] = oncall_team - return oncall_team + async def get_oncall_users(self, schedule_identifier: str) -> dict[str, Any]: + logger.debug(f"Fetching on-call users for schedule {schedule_identifier}") - async def get_oncall_user(self, schedule_identifier: str) -> dict[str, Any]: api_version = await self.get_resource_api_version(ObjectKind.SCHEDULE) url = f"{self.api_url}/{api_version}/schedules/{schedule_identifier}/on-calls?flat=true" return (await self._get_single_resource(url))["data"] - - async def get_schedule_by_team( - self, team_identifier: str - ) -> Optional[dict[str, Any]]: - schedules = [] - async for schedule_batch in self.get_paginated_resources(ObjectKind.SCHEDULE): - schedules.extend(schedule_batch) - return next( - ( - schedule - for schedule in schedules - if schedule["ownerTeam"]["id"] == team_identifier - ), - {}, - ) - - async def get_associated_alerts( - self, incident_identifier: str - ) -> list[dict[str, Any]]: - cache_key = f"{ObjectKind.INCIDENT}-{incident_identifier}" - if cache := event.attributes.get(cache_key): - return cache - - api_version = await self.get_resource_api_version(ObjectKind.INCIDENT) - url = f"{self.api_url}/{api_version}/incidents/{incident_identifier}/associated-alert-ids" - associated_alerts = (await self._get_single_resource(url))["data"] - event.attributes[cache_key] = associated_alerts - return associated_alerts - - async def get_impacted_services( - self, impacted_service_ids: list[str] - ) -> list[dict[str, Any]]: - services = [] - async for service_batch in self.get_paginated_resources(ObjectKind.SERVICE): - services.extend(service_batch) - service_dict = {service["id"]: service for service in services} - services_data = [ - service_dict[service_id] - for service_id in impacted_service_ids - if service_id in service_dict - ] - return services_data - - async def get_related_incident_by_alert( - self, alert: dict[str, Any] - ) -> dict[str, Any]: - incidents = [] - async for incident_batch in self.get_paginated_resources(ObjectKind.INCIDENT): - incidents.extend(incident_batch) - - for incident in incidents: - associated_alerts = await self.get_associated_alerts(incident["id"]) - if alert["id"] in associated_alerts: - alert["__relatedIncident"] = incident - break # Stop searching once a related incident is found - - return alert diff --git a/integrations/opsgenie/integration.py b/integrations/opsgenie/integration.py new file mode 100644 index 0000000000..5e6b1b866c --- /dev/null +++ b/integrations/opsgenie/integration.py @@ -0,0 +1,108 @@ +from typing import Any, Literal +from pydantic import Field, BaseModel + +from port_ocean.core.handlers import APIPortAppConfig +from port_ocean.core.handlers.port_app_config.models import ( + ResourceConfig, + PortAppConfig, + Selector, +) +from port_ocean.core.integrations.base import BaseIntegration + + +class APIQueryParams(BaseModel): + created_at: str | None = Field( + alias="createdAt", + description="The date and time the alert or incident was created", + ) + last_occurred_at: str | None = Field( + alias="lastOccurredAt", + description="The date and time the alert was last occurred", + ) + snoozed_until: str | None = Field( + alias="snoozedUntil", + description="The date and time the alert was snoozed until", + ) + message: str | None = Field(description="The message of the alert or incident") + status: Literal["open", "resolved", "closed"] | None = Field( + description="The status of the alert" + ) + is_seen: bool | None = Field(description="Whether the alert has been seen") + acknowledged: bool | None = Field( + description="Whether the alert has been acknowledged" + ) + snoozed: bool | None = Field(description="Whether the alert has been snoozed") + priority: Literal["P1", "P2", "P3", "P4", "P5"] | None = Field( + description="The priority of the alert" + ) + owner: str | None = Field( + description="The owner of the alert. Accepts OpsGenie username" + ) + teams: str | None = Field(description="The teams associated with the alert") + acknowledged_by: str | None = Field( + alias="acknowledgedBy", description="The user who acknowledged the alert" + ) + closed_by: str | None = Field( + alias="closedBy", description="The user who closed the alert" + ) + + def generate_request_params(self) -> dict[str, Any]: + params = [] + for field, value in self.dict(exclude_none=True).items(): + if isinstance(value, list): + params.append(f"{field}:{','.join(value)}") + else: + params.append(f"{field}:{value}") + + return {"query": " AND ".join(params)} + + class Config: + allow_population_by_field_name = True # This allows fields in a model to be populated either by their alias or by their field name + + +class ScheduleAPIQueryParams(BaseModel): + expand: Literal["rotation"] | None = Field( + description="The field to expand in the response" + ) + + def generate_request_params(self) -> dict[str, Any]: + value = self.dict(exclude_none=True) + if expand := value.pop("expand", None): + value["expand"] = expand + + return value + + +class AlertAndIncidentSelector(Selector): + api_query_params: APIQueryParams | None = Field( + alias="apiQueryParams", + description="The query parameters to filter alerts or incidents", + ) + + +class ScheduleSelector(Selector): + api_query_params: ScheduleAPIQueryParams | None = Field( + alias="apiQueryParams", + description="The query parameters to filter schedules", + ) + + +class AlertAndIncidentResourceConfig(ResourceConfig): + kind: Literal["alert", "incident"] + selector: AlertAndIncidentSelector + + +class ScheduleResourceConfig(ResourceConfig): + kind: Literal["schedule"] + selector: ScheduleSelector + + +class OpsGeniePortAppConfig(PortAppConfig): + resources: list[ + AlertAndIncidentResourceConfig | ScheduleResourceConfig | ResourceConfig + ] = Field(default_factory=list) + + +class OpsGenieIntegration(BaseIntegration): + class AppConfigHandlerClass(APIPortAppConfig): + CONFIG_CLASS = OpsGeniePortAppConfig diff --git a/integrations/opsgenie/main.py b/integrations/opsgenie/main.py index 160cc69c6b..e96663f054 100644 --- a/integrations/opsgenie/main.py +++ b/integrations/opsgenie/main.py @@ -1,10 +1,13 @@ -from typing import Any +from typing import Any, cast from loguru import logger import asyncio from port_ocean.core.ocean_types import ASYNC_GENERATOR_RESYNC_TYPE from port_ocean.context.ocean import ocean +from port_ocean.context.event import event from client import OpsGenieClient -from utils import ObjectKind +from utils import ObjectKind, ResourceKindsWithSpecialHandling + +from integration import AlertAndIncidentResourceConfig, ScheduleResourceConfig CONCURRENT_REQUESTS = 5 @@ -16,100 +19,117 @@ def init_client() -> OpsGenieClient: ) -async def enrich_services_with_team_data( +async def enrich_schedule_with_oncall_data( opsgenie_client: OpsGenieClient, semaphore: asyncio.Semaphore, - service: dict[str, Any], -) -> dict[str, Any]: - async with semaphore: - team_data, schedule = await asyncio.gather( - opsgenie_client.get_oncall_team(service["teamId"]), - opsgenie_client.get_schedule_by_team(service["teamId"]), - ) + schedule_batch: list[dict[str, Any]], +) -> list[dict[str, Any]]: - service["__team"] = team_data - if schedule: - service["__oncalls"] = await opsgenie_client.get_oncall_user(schedule["id"]) - return service + async def fetch_oncall(schedule_id: str) -> dict[str, Any]: + async with semaphore: + return await opsgenie_client.get_oncall_users(schedule_id) + oncall_tasks = [fetch_oncall(schedule["id"]) for schedule in schedule_batch] + results = await asyncio.gather(*oncall_tasks) -async def enrich_incident_with_alert_data( - opsgenie_client: OpsGenieClient, - semaphore: asyncio.Semaphore, - incident: dict[str, Any], -) -> dict[str, Any]: - async with semaphore: - if not incident["impactedServices"]: - return incident - impacted_services = await opsgenie_client.get_impacted_services( - incident["impactedServices"] - ) - incident["__impactedServices"] = impacted_services - return incident + for schedule, oncall_data in zip(schedule_batch, results): + schedule["__currentOncalls"] = oncall_data + return schedule_batch -async def enrich_alert_with_related_Incident_data( - opsgenie_client: OpsGenieClient, - semaphore: asyncio.Semaphore, - alert: dict[str, Any], -) -> dict[str, Any]: - async with semaphore: - alert_with_related_incident = ( - await opsgenie_client.get_related_incident_by_alert(alert) - ) - return alert_with_related_incident + +@ocean.on_resync() +async def on_resources_resync(kind: str) -> ASYNC_GENERATOR_RESYNC_TYPE: + + if kind in iter(ResourceKindsWithSpecialHandling): + logger.info(f"Kind {kind} has a special handling. Skipping...") + return + + opsgenie_client = init_client() + async for resource_batch in opsgenie_client.get_paginated_resources( + resource_type=ObjectKind(kind) + ): + logger.info(f"Received batch with {len(resource_batch)} {kind}") + yield resource_batch @ocean.on_resync(ObjectKind.SERVICE) async def on_service_resync(kind: str) -> ASYNC_GENERATOR_RESYNC_TYPE: opsgenie_client = init_client() - semaphore = asyncio.Semaphore(CONCURRENT_REQUESTS) async for service_batch in opsgenie_client.get_paginated_resources( resource_type=ObjectKind.SERVICE ): logger.info(f"Received batch with {len(service_batch)} services") - tasks = [ - enrich_services_with_team_data(opsgenie_client, semaphore, service) - for service in service_batch - ] - enriched_services = await asyncio.gather(*tasks) - yield enriched_services + yield service_batch @ocean.on_resync(ObjectKind.INCIDENT) async def on_incident_resync(kind: str) -> ASYNC_GENERATOR_RESYNC_TYPE: opsgenie_client = init_client() - semaphore = asyncio.Semaphore(CONCURRENT_REQUESTS) + selector = cast(AlertAndIncidentResourceConfig, event.resource_config).selector async for incident_batch in opsgenie_client.get_paginated_resources( - resource_type=ObjectKind.INCIDENT + resource_type=ObjectKind.INCIDENT, + query_params=( + selector.api_query_params.generate_request_params() + if selector.api_query_params + else None + ), ): - logger.info(f"Received batch with {len(incident_batch)} incident") - tasks = [ - enrich_incident_with_alert_data(opsgenie_client, semaphore, incident) - for incident in incident_batch - ] - enriched_incidents = await asyncio.gather(*tasks) - yield enriched_incidents + logger.info(f"Received batch with {len(incident_batch)} incidents") + yield incident_batch @ocean.on_resync(ObjectKind.ALERT) async def on_alert_resync(kind: str) -> ASYNC_GENERATOR_RESYNC_TYPE: opsgenie_client = init_client() - semaphore = asyncio.Semaphore(CONCURRENT_REQUESTS) + selector = cast(AlertAndIncidentResourceConfig, event.resource_config).selector async for alerts_batch in opsgenie_client.get_paginated_resources( - resource_type=ObjectKind.ALERT + resource_type=ObjectKind.ALERT, + query_params=( + selector.api_query_params.generate_request_params() + if selector.api_query_params + else None + ), ): logger.info(f"Received batch with {len(alerts_batch)} alerts") + yield alerts_batch + + +@ocean.on_resync(ObjectKind.SCHEDULE) +async def on_schedule_resync(kind: str) -> ASYNC_GENERATOR_RESYNC_TYPE: + opsgenie_client = init_client() + + selector = cast(ScheduleResourceConfig, event.resource_config).selector + async for schedules_batch in opsgenie_client.get_paginated_resources( + resource_type=ObjectKind.SCHEDULE, + query_params=( + selector.api_query_params.generate_request_params() + if selector.api_query_params + else None + ), + ): + logger.info(f"Received batch with {len(schedules_batch)} schedules") + yield schedules_batch - tasks = [ - enrich_alert_with_related_Incident_data(opsgenie_client, semaphore, alert) - for alert in alerts_batch - ] - enriched_alerts = await asyncio.gather(*tasks) - yield enriched_alerts + +@ocean.on_resync(ObjectKind.SCHEDULE_ONCALL) +async def on_schedule_oncall_resync(kind: str) -> ASYNC_GENERATOR_RESYNC_TYPE: + opsgenie_client = init_client() + + async for schedules_batch in opsgenie_client.get_paginated_resources( + resource_type=ObjectKind.SCHEDULE + ): + logger.info( + f"Received batch with {len(schedules_batch)} schedules, enriching with oncall data" + ) + semaphore = asyncio.Semaphore(CONCURRENT_REQUESTS) + schedule_oncall = await enrich_schedule_with_oncall_data( + opsgenie_client, semaphore, schedules_batch + ) + yield schedule_oncall @ocean.router.post("/webhook") diff --git a/integrations/opsgenie/pyproject.toml b/integrations/opsgenie/pyproject.toml index 280cb82a69..99c9a0e4c5 100644 --- a/integrations/opsgenie/pyproject.toml +++ b/integrations/opsgenie/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "opsgenie" -version = "0.1.77" +version = "0.2.0" description = "Ocean integration for OpsGenie" authors = ["Isaac Coffie "] diff --git a/integrations/opsgenie/utils.py b/integrations/opsgenie/utils.py index 2da2208d05..5ab38955d9 100644 --- a/integrations/opsgenie/utils.py +++ b/integrations/opsgenie/utils.py @@ -7,6 +7,7 @@ class ObjectKind(StrEnum): TEAM = "team" INCIDENT = "incident" SCHEDULE = "schedule" + SCHEDULE_ONCALL = "schedule-oncall" # A dictionary to map each resource type to its API version @@ -17,3 +18,11 @@ class ObjectKind(StrEnum): ObjectKind.INCIDENT: "v1", ObjectKind.SCHEDULE: "v2", } + + +class ResourceKindsWithSpecialHandling(StrEnum): + SERVICE = ObjectKind.SERVICE + ALERT = ObjectKind.ALERT + INCIDENT = ObjectKind.INCIDENT + SCHEDULE = ObjectKind.SCHEDULE + SCHEDULE_ONCALL = ObjectKind.SCHEDULE_ONCALL