Skip to content

Commit d21fcc6

Browse files
working on events funcs
all new code will go to the modular version. use run-modular.sh/bat
1 parent 3bd654c commit d21fcc6

File tree

10 files changed

+629
-36
lines changed

10 files changed

+629
-36
lines changed

api/appd_client.py

Lines changed: 176 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,18 @@
44
import requests
55
import pandas as pd
66
from io import StringIO
7-
from typing import Dict, Any, Optional, Tuple
7+
from typing import Dict, Any, Optional, Tuple, List
88
from urllib.parse import quote
99
from auth import AppDAuthenticator
10+
from utils import Logger
1011

1112

1213
class AppDAPIClient:
1314
"""Client for AppDynamics REST API calls."""
1415

15-
def __init__(self, authenticator: AppDAuthenticator):
16+
def __init__(self, authenticator: AppDAuthenticator, logger: Optional[Logger] = None):
1617
self.authenticator = authenticator
18+
self.logger = logger
1719

1820
def _make_request(self, method: str, url: str, **kwargs) -> Tuple[Optional[requests.Response], str]:
1921
"""
@@ -23,15 +25,21 @@ def _make_request(self, method: str, url: str, **kwargs) -> Tuple[Optional[reque
2325
Tuple of (response, status) where status is 'valid', 'error', or 'empty'
2426
"""
2527
if not self.authenticator.ensure_authenticated():
28+
if self.logger:
29+
self.logger.error("Authentication not valid; cannot make request")
2630
return None, "error"
2731

2832
session = self.authenticator.get_session()
2933
if not session:
3034
return None, "error"
3135

3236
try:
37+
if self.logger:
38+
self.logger.debug(f"HTTP {method} {url}")
3339
response = session.request(method, url, verify=self.authenticator.verify_ssl, **kwargs)
3440
response.raise_for_status()
41+
if self.logger:
42+
self.logger.debug(f"HTTP {response.status_code} OK for {url}")
3543
return response, "valid"
3644
except requests.exceptions.HTTPError as err:
3745
error_map = {
@@ -42,16 +50,28 @@ def _make_request(self, method: str, url: str, **kwargs) -> Tuple[Optional[reque
4250
500: "Internal Server Error - Something went wrong on the server.",
4351
}
4452
error_explanation = error_map.get(err.response.status_code, "Unknown HTTP Error")
45-
print(f"HTTP Error: {err.response.status_code} - {error_explanation}")
53+
if self.logger:
54+
self.logger.error(f"HTTP Error {err.response.status_code}: {error_explanation}")
55+
else:
56+
print(f"HTTP Error: {err.response.status_code} - {error_explanation}")
4657
return None, "error"
4758
except requests.exceptions.RequestException as err:
4859
if isinstance(err, requests.exceptions.ConnectionError):
49-
print("Connection Error: Failed to establish a new connection.")
60+
if self.logger:
61+
self.logger.error("Connection Error: Failed to establish a new connection.")
62+
else:
63+
print("Connection Error: Failed to establish a new connection.")
5064
else:
51-
print(f"Request Exception: {err}")
65+
if self.logger:
66+
self.logger.error(f"Request Exception: {err}")
67+
else:
68+
print(f"Request Exception: {err}")
5269
return None, "error"
5370
except Exception as err:
54-
print(f"Unexpected Error: {type(err).__name__}: {err}")
71+
if self.logger:
72+
self.logger.error(f"Unexpected Error: {type(err).__name__}: {err}")
73+
else:
74+
print(f"Unexpected Error: {type(err).__name__}: {err}")
5575
return None, "error"
5676

5777
def _urlencode_string(self, text: str) -> str:
@@ -161,6 +181,156 @@ def get_apm_availability(self, object_type: str, app_name: str, tier_name: str,
161181

162182
return self._make_request("GET", url)
163183

184+
def get_health_rule_violations(
185+
self,
186+
application_id: str,
187+
time_range_type: str = "BEFORE_NOW",
188+
duration_mins: Optional[int] = None,
189+
start_time: Optional[int] = None,
190+
end_time: Optional[int] = None,
191+
output: str = "XML",
192+
) -> Tuple[Optional[requests.Response], str]:
193+
"""Get health rule violations for a specific application.
194+
195+
Docs: https://docs.appdynamics.com/appd/24.x/latest/en/extend-splunk-appdynamics/splunk-appdynamics-apis/alert-and-respond-api/events-api
196+
Endpoint: /controller/rest/applications/{application_id}/problems/healthrule-violations
197+
"""
198+
base = f"{self.authenticator.credentials.base_url}/controller/rest/applications/{application_id}/problems/healthrule-violations"
199+
200+
params = {"time-range-type": time_range_type}
201+
if duration_mins is not None:
202+
params["duration-in-mins"] = str(duration_mins)
203+
if start_time is not None:
204+
params["start-time"] = str(start_time)
205+
if end_time is not None:
206+
params["end-time"] = str(end_time)
207+
if output:
208+
params["output"] = output
209+
210+
# Build URL with query params
211+
query = "&".join([f"{k}={v}" for k, v in params.items()])
212+
url = f"{base}?{query}"
213+
return self._make_request("GET", url)
214+
215+
def get_events(
216+
self,
217+
application_id: str,
218+
event_types: List[str],
219+
severities: List[str],
220+
time_range_type: str = "BEFORE_NOW",
221+
duration_mins: Optional[int] = None,
222+
start_time: Optional[int] = None,
223+
end_time: Optional[int] = None,
224+
tier: Optional[str] = None,
225+
output: str = "XML",
226+
) -> Tuple[Optional[requests.Response], str]:
227+
"""Get events for a specific application.
228+
229+
Endpoint: /controller/rest/applications/{application_id}/events
230+
Notes: Returns at most 600 events per request.
231+
"""
232+
base = f"{self.authenticator.credentials.base_url}/controller/rest/applications/{application_id}/events"
233+
234+
params: Dict[str, Any] = {
235+
"time-range-type": time_range_type,
236+
"event-types": ",".join(event_types) if event_types else "",
237+
"severities": ",".join(severities) if severities else "",
238+
}
239+
if duration_mins is not None:
240+
params["duration-in-mins"] = str(duration_mins)
241+
if start_time is not None:
242+
params["start-time"] = str(start_time)
243+
if end_time is not None:
244+
params["end-time"] = str(end_time)
245+
if tier:
246+
params["tier"] = self._urlencode_string(tier)
247+
if output:
248+
params["output"] = output
249+
250+
query = "&".join([f"{k}={v}" for k, v in params.items() if v is not None and v != ""])
251+
url = f"{base}?{query}"
252+
return self._make_request("GET", url)
253+
254+
def get_events_paginated(
255+
self,
256+
application_id: str,
257+
event_types: List[str],
258+
severities: List[str],
259+
duration_mins: int,
260+
tier: Optional[str] = None,
261+
) -> List[Dict[str, Any]]:
262+
"""Retrieve all matching events using sliding windows to respect the 600-per-request cap.
263+
264+
Strategy:
265+
- Use BEFORE_NOW with duration windows; if a window returns 600 rows, bisect the window to reduce size
266+
- Continue until windows produce < 600, then slide backward until total duration is covered
267+
- Parse XML to DataFrame via pandas.read_xml at the call-site; here we just fetch text
268+
"""
269+
import time as _time
270+
import math as _math
271+
from io import StringIO as _StringIO
272+
import pandas as _pd
273+
274+
all_events_df = _pd.DataFrame()
275+
276+
# Start with the full duration; adaptively split if needed
277+
remaining = duration_mins
278+
while remaining > 0:
279+
window = min(remaining, 240) # start with 4h windows to balance calls/results
280+
response, status = self.get_events(
281+
application_id=application_id,
282+
event_types=event_types,
283+
severities=severities,
284+
time_range_type="BEFORE_NOW",
285+
duration_mins=window,
286+
tier=tier,
287+
output="XML",
288+
)
289+
if status != "valid" or not response:
290+
break
291+
292+
# Parse quickly to count rows
293+
try:
294+
xml_content = _StringIO(response.content.decode())
295+
df = _pd.read_xml(xml_content, xpath=".//event")
296+
except Exception:
297+
df = _pd.DataFrame()
298+
299+
if df is None or df.empty:
300+
remaining -= window
301+
continue
302+
303+
if len(df) >= 600 and window > 5:
304+
# Too many results; reduce window size and retry without consuming remaining time
305+
new_window = max(5, window // 2)
306+
if new_window == window:
307+
new_window = 5
308+
# small delay to avoid rate-limits
309+
_time.sleep(0.2)
310+
# retry with smaller window
311+
response, status = self.get_events(
312+
application_id=application_id,
313+
event_types=event_types,
314+
severities=severities,
315+
time_range_type="BEFORE_NOW",
316+
duration_mins=new_window,
317+
tier=tier,
318+
output="XML",
319+
)
320+
try:
321+
xml_content = _StringIO(response.content.decode())
322+
df = _pd.read_xml(xml_content, xpath=".//event")
323+
except Exception:
324+
df = _pd.DataFrame()
325+
window = new_window
326+
327+
all_events_df = _pd.concat([all_events_df, df])
328+
remaining -= window
329+
_time.sleep(0.2)
330+
331+
# Return as list of dicts for caller flexibility
332+
return all_events_df.to_dict(orient="records")
333+
164334

165335

166336

config/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
Configuration package for AppDynamics Data Extractor.
33
"""
44
from .settings import AppConfig, APICredentials, get_config
5+
from .event_types import EVENT_TYPES, SEVERITY_LEVELS, ALL_EVENT_TYPES
56
from .secrets_manager import SecretsManager
67

7-
__all__ = ['AppConfig', 'APICredentials', 'get_config', 'SecretsManager']
8+
__all__ = ['AppConfig', 'APICredentials', 'get_config', 'SecretsManager', 'EVENT_TYPES', 'SEVERITY_LEVELS', 'ALL_EVENT_TYPES']
89

910

1011

config/event_types.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""
2+
Canonical event type constants for the AppDynamics Events API.
3+
4+
Note: AppDynamics' public documentation lists many event types but is not
5+
guaranteed to be exhaustive for every controller/version, especially for
6+
CUSTOM events. This list is intentionally broad and grouped by category to
7+
aid discoverability in the UI. Users can still filter by any subset.
8+
"""
9+
10+
from typing import Dict, List
11+
12+
13+
# Severity levels supported by the Events API retrieval endpoint
14+
SEVERITY_LEVELS: List[str] = [
15+
"INFO",
16+
"WARN",
17+
"ERROR",
18+
]
19+
20+
21+
# Grouped event types for easier selection in the UI
22+
# These are commonly observed/documented event types; the controller may emit
23+
# a subset/superset depending on features and version.
24+
EVENT_TYPES: Dict[str, List[str]] = {
25+
"Application": [
26+
"APPLICATION_ERROR",
27+
"APPLICATION_DEPLOYMENT",
28+
"APPLICATION_CONFIG_CHANGE",
29+
"CUSTOM",
30+
],
31+
"Diagnostics": [
32+
"DIAGNOSTIC_SESSION",
33+
"ERROR_DIAGNOSTIC_SESSION",
34+
"SLOW_DIAGNOSTIC_SESSION",
35+
"DEADLOCK",
36+
"MEMORY_LEAK_DIAGNOSTICS",
37+
],
38+
"Discovery": [
39+
"BACKEND_DISCOVERED",
40+
"SERVICE_ENDPOINT_DISCOVERED",
41+
],
42+
"Agent": [
43+
"AGENT_EVENT",
44+
"AGENT_STATUS",
45+
"AGENT_CONFIGURATION",
46+
],
47+
# Health rule/policy lifecycle often appears via the dedicated
48+
# healthrule-violations endpoint, but some controllers also surface
49+
# these as events.
50+
"Policy": [
51+
"POLICY_OPEN_WARNING",
52+
"POLICY_OPEN_CRITICAL",
53+
"POLICY_CLOSE_WARNING",
54+
"POLICY_CLOSE_CRITICAL",
55+
],
56+
}
57+
58+
59+
# Convenience: a flat, de-duplicated list of all known event types
60+
ALL_EVENT_TYPES: List[str] = sorted({t for group in EVENT_TYPES.values() for t in group})
61+
62+

config/settings.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
Configuration settings for AppDynamics Data Extractor.
33
"""
44
import os
5-
from dataclasses import dataclass
6-
from typing import Optional
5+
from dataclasses import dataclass, field
6+
from typing import Optional, List
77

88

99
@dataclass
@@ -35,6 +35,14 @@ class AppConfig:
3535
# Output settings
3636
keep_status_open: bool = False
3737

38+
# Event settings
39+
default_event_duration: int = 60
40+
enable_health_rule_violations: bool = False
41+
enable_general_events: bool = False
42+
enable_custom_events: bool = False
43+
selected_event_types: List[str] = field(default_factory=list)
44+
selected_severities: List[str] = field(default_factory=lambda: ["INFO", "WARN", "ERROR"])
45+
3846

3947
@dataclass
4048
class APICredentials:

0 commit comments

Comments
 (0)