Skip to content

Commit bfc83d0

Browse files
Merge pull request #47 from codedwithhs/feature-AIRA-36-Connector---Implement-Datadog
AIRA-36: Connector - Implement Datadog
2 parents 5c72883 + 997c49a commit bfc83d0

File tree

9 files changed

+223
-8
lines changed

9 files changed

+223
-8
lines changed

aira/config.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,21 @@ class JSMConfig(BaseModel):
5151
api_token: SecretStr
5252

5353

54+
class DatadogConfig(BaseModel):
55+
type: Literal["datadog"]
56+
api_key: SecretStr
57+
app_key: SecretStr
58+
# Datadog site URL varies by region (e.g., datadoghq.com, datadoghq.eu)
59+
site: Optional[str] = "datadoghq.com"
60+
61+
5462
class SlackConfig(BaseModel):
5563
type: Literal["slack"]
5664
webhook_url: SecretStr
5765

5866

5967
# Discriminated Unions for Connectors and Actions
60-
AnyConnection = Union[GitHubConfig, PagerDutyConfig, JSMConfig]
68+
AnyConnection = Union[GitHubConfig, PagerDutyConfig, JSMConfig, DatadogConfig]
6169
AnyAction = Union[SlackConfig]
6270

6371

aira/connectors/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
from .source_control import GitHubConnector
66
from .alerting import PagerDutyConnector
77
from .alerting import JSMConnector
8-
from .collaboration.slack import SlackConnector
8+
from .collaboration import SlackConnector
9+
from .observability import DatadogConnector
910

1011

1112
# The Registry Map: Maps a 'type' string from config to the actual class.
@@ -15,6 +16,7 @@
1516
"jsm": JSMConnector,
1617
"github": GitHubConnector,
1718
"slack": SlackConnector,
19+
"datadog": DatadogConnector,
1820
}
1921

2022

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# aira/connectors/obervability/__init__.py
2+
3+
from .datadog import DatadogConnector
4+
5+
# This line explicitly declares which names are part of this package's
6+
# public interface, silencing the "unused import" warning.
7+
__all__ = ["DatadogConnector"]
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import requests
2+
from typing import Dict, Any, Tuple
3+
from datetime import datetime, timedelta, timezone
4+
5+
from ..base import ObservabilityProvider
6+
from ...config import DatadogConfig
7+
8+
9+
class DatadogConnector(ObservabilityProvider):
10+
"""
11+
Connector for interacting with the Datadog API.
12+
"""
13+
14+
def __init__(self, name: str, config: Dict[str, Any]):
15+
"""
16+
Initializes the connector and validates its specific configuration.
17+
"""
18+
super().__init__(name, config)
19+
self.validated_config = DatadogConfig(**self.config)
20+
self.api_base_url = f"https://api.{self.validated_config.site}"
21+
self.headers = {
22+
"DD-API-KEY": self.validated_config.api_key.get_secret_value(),
23+
"DD-APPLICATION-KEY": self.validated_config.app_key.get_secret_value(),
24+
"Content-Type": "application/json",
25+
}
26+
27+
def test_connection(self) -> Tuple[bool, str]:
28+
"""
29+
Validates the Datadog API and App keys by making a lightweight API call.
30+
"""
31+
try:
32+
# The validate endpoint is designed for this purpose
33+
response = requests.get(
34+
f"{self.api_base_url}/api/v1/validate", headers=self.headers, timeout=10
35+
)
36+
response.raise_for_status()
37+
if response.json().get("valid"):
38+
return True, "Datadog connection successful and keys are valid."
39+
else:
40+
return (
41+
False,
42+
"Datadog connection failed: The provided keys are not valid.",
43+
)
44+
except requests.exceptions.HTTPError as e:
45+
if e.response.status_code in [401, 403]:
46+
return False, "Connection failed: Invalid Datadog API or App Key."
47+
return False, f"Connection failed: HTTP {e.response.status_code} error."
48+
except requests.exceptions.RequestException as e:
49+
return False, f"Connection failed: Network error - {e}."
50+
51+
def fetch_logs(self, query: str, time_window_minutes: int = 15) -> str:
52+
"""
53+
Fetches and formats logs from Datadog Logs.
54+
55+
Args:
56+
query (str): The search query to execute (e.g., 'service:api-checkout status:error').
57+
time_window_minutes (int): The number of minutes to look back for logs.
58+
59+
Returns:
60+
A formatted string of log lines or an error/empty message.
61+
"""
62+
url = f"{self.api_base_url}/api/v2/logs/events/search"
63+
print(f"-> Fetching logs from Datadog with query: '{query}'...")
64+
65+
now = datetime.now(timezone.utc)
66+
from_time = now - timedelta(minutes=time_window_minutes)
67+
68+
payload = {
69+
"filter": {
70+
"query": query,
71+
"from": from_time.isoformat(),
72+
"to": now.isoformat(),
73+
},
74+
"sort": "-timestamp",
75+
"page": {
76+
"limit": 25 # Limit to the most recent 25 logs to keep context concise
77+
},
78+
}
79+
80+
try:
81+
response = requests.post(
82+
url, headers=self.headers, json=payload, timeout=15
83+
)
84+
response.raise_for_status()
85+
logs = response.json().get("data", [])
86+
87+
if not logs:
88+
return f"No logs found in Datadog for query '{query}' in the last {time_window_minutes} minutes."
89+
90+
summaries = [
91+
f"- [{log['attributes'].get('status', 'INFO').upper()}] {log['attributes'].get('message', '')}"
92+
for log in logs
93+
]
94+
print(f" ...found {len(logs)} log entries.")
95+
return "\n".join(summaries)
96+
except requests.exceptions.HTTPError as e:
97+
return f"Error: Could not fetch logs from Datadog. HTTP {e.response.status_code}."
98+
except requests.exceptions.RequestException as e:
99+
return f"Error: Network issue while fetching logs from Datadog: {e}"

aira/templates/config.template.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ connections:
3333
user_email: [email protected]
3434
api_token: "${JSM_API_TOKEN}"
3535

36+
datadog_us1:
37+
type: datadog
38+
api_key: "${DD_API_KEY}"
39+
app_key: "${DD_APP_KEY}"
40+
site: DD_SITE_URL_PLACEHOLDER # Use "datadoghq.eu" for EU region
41+
3642
# --- Actions Configuration ---
3743
# Define all the services the agent can take action on.
3844
actions:

aira/templates/non_secret_prompts.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,10 @@
2828
"prompt": "What is the email address for your JSM user?",
2929
"placeholder": "[email protected]",
3030
"default": "[email protected]"
31+
},
32+
"dd_site_url": {
33+
"prompt": "What is the Datadog site URL?",
34+
"placeholder": "datadoghq.com or datadoghq.eu",
35+
"default": "datadoghq.com"
3136
}
3237
}

aira/templates/secret_prompts.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,5 +18,13 @@
1818
"JSM_API_TOKEN": {
1919
"prompt": "Enter your JSM API Token",
2020
"url": "https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/"
21+
},
22+
"DD_API_KEY": {
23+
"prompt": "Enter your Datadog API Key",
24+
"url": "https://docs.datadoghq.com/account_management/api-app-keys/"
25+
},
26+
"DD_APP_KEY": {
27+
"prompt": "Enter your Datadog Application Key",
28+
"url": "https://docs.datadoghq.com/account_management/api-app-keys"
2129
}
2230
}

config.example.yaml

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,11 @@ connections:
4949
user_email: [email protected]
5050
api_token: "${JSM_API_TOKEN}"
5151

52-
# --- Example for a future Datadog Connector ---
53-
# datadog_us1:
54-
# type: datadog
55-
# api_key: "${DD_API_KEY}"
56-
# app_key: "${DD_APP_KEY}"
57-
# site: "datadoghq.com" # Use "datadoghq.eu" for EU region
52+
datadog_us1:
53+
type: datadog
54+
api_key: "${DD_API_KEY}"
55+
app_key: "${DD_APP_KEY}"
56+
site: "datadoghq.com" # Use "datadoghq.eu" for EU region
5857

5958

6059
# --- Actions Configuration ---
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import pytest
2+
from pydantic import ValidationError
3+
from aira.connectors.observability.datadog import DatadogConnector
4+
5+
6+
@pytest.fixture
7+
def valid_datadog_config() -> dict:
8+
"""Provides a valid configuration dictionary for the Datadog connector."""
9+
return {
10+
"type": "datadog",
11+
"api_key": "fake_api_key",
12+
"app_key": "fake_app_key",
13+
"site": "datadoghq.com",
14+
}
15+
16+
17+
def test_datadog_connector_instantiation_success(valid_datadog_config):
18+
"""Tests that the connector initializes successfully with valid config."""
19+
try:
20+
DatadogConnector(name="test_datadog", config=valid_datadog_config)
21+
except (ValueError, ValidationError) as e:
22+
pytest.fail(f"Connector instantiation failed unexpectedly: {e}")
23+
24+
25+
def test_datadog_connector_instantiation_missing_keys():
26+
"""Tests that instantiation fails if required keys are missing."""
27+
with pytest.raises(ValidationError):
28+
DatadogConnector(name="test_datadog", config={"type": "datadog"})
29+
30+
31+
def test_connection_success(requests_mock, valid_datadog_config):
32+
"""Tests a successful connection validation."""
33+
requests_mock.get(
34+
"https://api.datadoghq.com/api/v1/validate",
35+
json={"valid": True},
36+
status_code=200,
37+
)
38+
connector = DatadogConnector(name="test_datadog", config=valid_datadog_config)
39+
success, message = connector.test_connection()
40+
assert success is True
41+
assert "connection successful and keys are valid" in message
42+
43+
44+
def test_connection_failure_invalid_keys(requests_mock, valid_datadog_config):
45+
"""Tests a failed connection due to invalid keys (HTTP 403)."""
46+
requests_mock.get("https://api.datadoghq.com/api/v1/validate", status_code=403)
47+
connector = DatadogConnector(name="test_datadog", config=valid_datadog_config)
48+
success, message = connector.test_connection()
49+
assert success is False
50+
assert "Invalid Datadog API or App Key" in message
51+
52+
53+
def test_fetch_logs_success(requests_mock, valid_datadog_config):
54+
"""Tests successfully fetching and formatting logs."""
55+
mock_response = {
56+
"data": [
57+
{"attributes": {"status": "error", "message": "Service unavailable"}},
58+
{"attributes": {"status": "info", "message": "User login successful"}},
59+
]
60+
}
61+
requests_mock.post(
62+
"https://api.datadoghq.com/api/v2/logs/events/search",
63+
json=mock_response,
64+
status_code=200,
65+
)
66+
connector = DatadogConnector(name="test_datadog", config=valid_datadog_config)
67+
result = connector.fetch_logs(query="service:test", time_window_minutes=10)
68+
assert "[ERROR] Service unavailable" in result
69+
assert "[INFO] User login successful" in result
70+
71+
72+
def test_fetch_logs_no_logs_found(requests_mock, valid_datadog_config):
73+
"""Tests the response when no logs are found."""
74+
requests_mock.post(
75+
"https://api.datadoghq.com/api/v2/logs/events/search",
76+
json={"data": []},
77+
status_code=200,
78+
)
79+
connector = DatadogConnector(name="test_datadog", config=valid_datadog_config)
80+
result = connector.fetch_logs(query="service:test", time_window_minutes=10)
81+
assert "No logs found" in result

0 commit comments

Comments
 (0)