diff --git a/integrations/newrelic/.port/resources/blueprints.json b/integrations/newrelic/.port/resources/blueprints.json index e8bb792b46..3a5e9edbc1 100644 --- a/integrations/newrelic/.port/resources/blueprints.json +++ b/integrations/newrelic/.port/resources/blueprints.json @@ -133,5 +133,59 @@ "mirrorProperties": {}, "calculationProperties": {}, "relations": {} + }, + { + "identifier": "newRelicServiceLevel", + "description": "This blueprint represents a New Relic Service Level", + "title": "New Relic Service Level", + "icon": "NewRelic", + "schema": { + "properties": { + "description": { + "title": "Description", + "type": "string" + }, + "targetThreshold": { + "icon": "DefaultProperty", + "title": "Target Threshold", + "type": "number" + }, + "createdAt": { + "title": "Created At", + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "title": "Updated At", + "type": "string", + "format": "date-time" + }, + "createdBy": { + "title": "Creator", + "type": "string", + "format": "user" + }, + "sli": { + "type": "number", + "title": "SLI" + }, + "tags": { + "type": "object", + "title": "Tags" + } + }, + "required": [] + }, + "mirrorProperties": {}, + "calculationProperties": {}, + "aggregationProperties": {}, + "relations": { + "newRelicService": { + "title": "New Relic service", + "target": "newRelicService", + "required": false, + "many": false + } + } } ] diff --git a/integrations/newrelic/.port/resources/port-app-config.yaml b/integrations/newrelic/.port/resources/port-app-config.yaml index 903141f68d..390b644cb4 100644 --- a/integrations/newrelic/.port/resources/port-app-config.yaml +++ b/integrations/newrelic/.port/resources/port-app-config.yaml @@ -71,3 +71,22 @@ resources: activatedAt: .activatedAt relations: newRelicService: ".__APPLICATION.entity_guids + .__SERVICE.entity_guids" + - kind: newRelicServiceLevel + selector: + query: 'true' + port: + entity: + mappings: + blueprint: '"newRelicServiceLevel"' + identifier: .serviceLevel.indicators[0].id + title: .serviceLevel.indicators[0].name + properties: + description: .serviceLevel.indicators[0].description + targetThreshold: .serviceLevel.indicators[0].objectives[0].target + createdAt: if .serviceLevel.indicators[0].createdAt != null then (.serviceLevel.indicators[0].createdAt | tonumber / 1000 | todate) else null end + updatedAt: .serviceLevel.indicators[0].updatedAt + createdBy: .serviceLevel.indicators[0].createdBy.email + sli: .__SLI.SLI + tags: .tags + relations: + newRelicService: .serviceLevel.indicators[0].guid diff --git a/integrations/newrelic/.port/spec.yaml b/integrations/newrelic/.port/spec.yaml index 471d3b4c28..5edfa61f05 100644 --- a/integrations/newrelic/.port/spec.yaml +++ b/integrations/newrelic/.port/spec.yaml @@ -7,6 +7,7 @@ features: resources: - kind: newRelicService - kind: newRelicAlert + - kind: newRelicServiceLevel configurations: - name: newRelicAPIKey required: true diff --git a/integrations/newrelic/CHANGELOG.md b/integrations/newrelic/CHANGELOG.md index f76e475e4e..467f03dbb2 100644 --- a/integrations/newrelic/CHANGELOG.md +++ b/integrations/newrelic/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 <!-- towncrier release notes start --> +## 0.1.70 (2024-08-15) + + +### Improvements + +- Added support for service level indicators and objectives + + ## 0.1.69 (2024-08-13) diff --git a/integrations/newrelic/newrelic_integration/core/entities.py b/integrations/newrelic/newrelic_integration/core/entities.py index 4a5365fa88..2a5de74f37 100644 --- a/integrations/newrelic/newrelic_integration/core/entities.py +++ b/integrations/newrelic/newrelic_integration/core/entities.py @@ -9,7 +9,7 @@ LIST_ENTITIES_BY_GUIDS_QUERY, GET_ENTITY_BY_GUID_QUERY, ) -from newrelic_integration.core.utils import send_graph_api_request +from newrelic_integration.core.utils import send_graph_api_request, format_tags from newrelic_integration.utils import ( get_port_resource_configuration_by_port_kind, render_query, @@ -34,7 +34,7 @@ async def get_entity(self, entity_guid: str) -> dict[Any, Any]: raise NewRelicNotFoundError( f"No entity found in newrelic for guid {entity_guid}", ) - self._format_tags(entity) + format_tags(entity) return entity async def list_entities_by_resource_kind( @@ -74,7 +74,7 @@ async def extract_entities( entity_query_filter=resource_config.selector.entity_query_filter, extra_entity_properties=resource_config.selector.entity_extra_properties_query, ): - self._format_tags(entity) + format_tags(entity) yield entity async def list_entities_by_guids( @@ -92,7 +92,7 @@ async def list_entities_by_guids( ) entities = response.get("data", {}).get("actor", {}).get("entities", []) for entity in entities: - self._format_tags(entity) + format_tags(entity) return entities @staticmethod diff --git a/integrations/newrelic/newrelic_integration/core/query_templates/service_levels.py b/integrations/newrelic/newrelic_integration/core/query_templates/service_levels.py new file mode 100644 index 0000000000..59a6c22ca6 --- /dev/null +++ b/integrations/newrelic/newrelic_integration/core/query_templates/service_levels.py @@ -0,0 +1,63 @@ +GET_SLI_BY_NRQL_QUERY = """ +{ + actor { + account(id: {{ account_id }}) { + nrql(query: "{{ nrql_query }}") { + results + } + } + } +} +""" + +LIST_SLOS_QUERY = """ +{ + actor { + entitySearch(query: "type ='SERVICE_LEVEL'") { + count + query + results{{ next_cursor_request }} { + entities { + serviceLevel { + indicators { + resultQueries { + indicator { + nrql + } + } + id + name + description + createdBy { + email + } + guid + updatedAt + createdAt + updatedBy { + email + } + objectives { + description + target + name + timeWindow { + rolling { + count + unit + } + } + } + } + } + tags { + key + values + } + } + nextCursor + } + } + } +} +""" diff --git a/integrations/newrelic/newrelic_integration/core/service_levels.py b/integrations/newrelic/newrelic_integration/core/service_levels.py new file mode 100644 index 0000000000..99a4344f2f --- /dev/null +++ b/integrations/newrelic/newrelic_integration/core/service_levels.py @@ -0,0 +1,89 @@ +from typing import Any, AsyncIterable, Tuple, Optional +import httpx +from port_ocean.context.ocean import ocean +from newrelic_integration.core.query_templates.service_levels import ( + LIST_SLOS_QUERY, + GET_SLI_BY_NRQL_QUERY, +) +from newrelic_integration.core.utils import send_graph_api_request, format_tags +from newrelic_integration.utils import ( + render_query, +) +from newrelic_integration.core.paging import send_paginated_graph_api_request + +SLI_OBJECT = "__SLI" +BATCH_SIZE = 50 + + +class ServiceLevelsHandler: + def __init__(self, http_client: httpx.AsyncClient): + self.http_client = http_client + + async def get_service_level_indicator_value( + self, http_client: httpx.AsyncClient, nrql: str + ) -> dict[Any, Any]: + query = await render_query( + GET_SLI_BY_NRQL_QUERY, + nrql_query=nrql, + account_id=ocean.integration_config.get("new_relic_account_id"), + ) + response = await send_graph_api_request( + http_client, query, request_type="get_service_level_indicator_value" + ) + service_levels = ( + response.get("data", {}) + .get("actor", {}) + .get("account", {}) + .get("nrql", {}) + .get("results", []) + ) + if service_levels: + return service_levels[0] + return {} + + async def enrich_slo_with_sli_and_tags( + self, service_level: dict[str, Any] + ) -> dict[str, Any]: + # Get the NRQL which is used to build the actual SLI result + nrql = ( + service_level.get("serviceLevel", {}) + .get("indicators", [])[0] + .get("resultQueries", {}) + .get("indicator", {}) + .get("nrql") + ) + service_level[SLI_OBJECT] = await self.get_service_level_indicator_value( + self.http_client, nrql + ) + format_tags(service_level) + return service_level + + async def list_service_levels(self) -> AsyncIterable[list[dict[str, Any]]]: + batch = [] + async for service_level in send_paginated_graph_api_request( + self.http_client, + LIST_SLOS_QUERY, + request_type="list_service_levels", + extract_data=self._extract_service_levels, + ): + batch.append(service_level) + + if len(batch) >= BATCH_SIZE: + yield batch + batch = [] # Clearing the batch for the next set of items + + if batch: + yield batch + + @staticmethod + async def _extract_service_levels( + response: dict[Any, Any] + ) -> Tuple[Optional[str], list[dict[Any, Any]]]: + """Extract service levels from the response. used by send_paginated_graph_api_request""" + results = ( + response.get("data", {}) + .get("actor", {}) + .get("entitySearch", {}) + .get("results", {}) + ) + return results.get("nextCursor"), results.get("entities", []) diff --git a/integrations/newrelic/newrelic_integration/core/utils.py b/integrations/newrelic/newrelic_integration/core/utils.py index e72e11bc9a..f756921486 100644 --- a/integrations/newrelic/newrelic_integration/core/utils.py +++ b/integrations/newrelic/newrelic_integration/core/utils.py @@ -36,3 +36,8 @@ async def send_graph_api_request( logger.debug("Received graph api response", **log_fields) response.raise_for_status() return response.json() + + +def format_tags(entity: dict[Any, Any]) -> dict[Any, Any]: + entity["tags"] = {tag["key"]: tag["values"] for tag in entity.get("tags", [])} + return entity diff --git a/integrations/newrelic/newrelic_integration/ocean.py b/integrations/newrelic/newrelic_integration/ocean.py index f2488acd2f..263b1ede47 100644 --- a/integrations/newrelic/newrelic_integration/ocean.py +++ b/integrations/newrelic/newrelic_integration/ocean.py @@ -1,15 +1,29 @@ +from typing import Any import httpx +import asyncio from loguru import logger from port_ocean.context.ocean import ocean from port_ocean.core.ocean_types import ASYNC_GENERATOR_RESYNC_TYPE - from newrelic_integration.core.entities import EntitiesHandler from newrelic_integration.core.issues import IssuesHandler, IssueState, IssueEvent +from newrelic_integration.core.service_levels import ServiceLevelsHandler + from newrelic_integration.utils import ( get_port_resource_configuration_by_newrelic_entity_type, get_port_resource_configuration_by_port_kind, ) +SERVICE_LEVEL_MAX_CONCURRENT_REQUESTS = 10 + + +async def enrich_service_level( + handler: ServiceLevelsHandler, + semaphore: asyncio.Semaphore, + service_level: dict[str, Any], +) -> dict[str, Any]: + async with semaphore: + return await handler.enrich_slo_with_sli_and_tags(service_level) + @ocean.on_resync() async def resync_entities(kind: str) -> ASYNC_GENERATOR_RESYNC_TYPE: @@ -63,6 +77,24 @@ async def resync_issues(kind: str) -> ASYNC_GENERATOR_RESYNC_TYPE: yield await IssuesHandler(http_client).list_issues() +@ocean.on_resync(kind="newRelicServiceLevel") +async def resync_service_levels(kind: str) -> ASYNC_GENERATOR_RESYNC_TYPE: + with logger.contextualize(resource_kind=kind): + async with httpx.AsyncClient() as http_client: + service_level_handler = ServiceLevelsHandler(http_client) + semaphore = asyncio.Semaphore(SERVICE_LEVEL_MAX_CONCURRENT_REQUESTS) + + async for service_levels in service_level_handler.list_service_levels(): + tasks = [ + enrich_service_level( + service_level_handler, semaphore, service_level + ) + for service_level in service_levels + ] + enriched_service_levels = await asyncio.gather(*tasks) + yield enriched_service_levels + + @ocean.router.post("/events") async def handle_issues_events(issue: IssueEvent) -> dict[str, bool]: with logger.contextualize(issue_id=issue.id, issue_state=issue.state): diff --git a/integrations/newrelic/pyproject.toml b/integrations/newrelic/pyproject.toml index 8604ef806f..1b7e050f48 100644 --- a/integrations/newrelic/pyproject.toml +++ b/integrations/newrelic/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "newrelic" -version = "0.1.69" +version = "0.1.70" description = "New Relic Integration" authors = ["Tom Tankilevitch <tomtankilevitch@gmail.com>"]