Skip to content

Commit df047ab

Browse files
authored
Merge pull request #138 from ns1/PENG-6541/usage-alerts-py
usage alerts (account)
2 parents eb1c0e4 + 65f9c75 commit df047ab

File tree

7 files changed

+563
-1
lines changed

7 files changed

+563
-1
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 0.25.0 (August 19th, 2025)
2+
3+
ENHANCEMENTS:
4+
* Add Usage Alerts (account): create/get/patch/delete/list; client-side validation; examples. Now accessible via alerts().usage.
5+
16
## 0.24.0 (March 20th, 2025)
27

38
ENHANCEMENTS:

examples/usage_alerts.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
#!/usr/bin/env python3
2+
#
3+
# Example of using the Usage Alerts API
4+
#
5+
6+
import os
7+
import json
8+
from ns1 import NS1
9+
from ns1.config import Config
10+
11+
# Create NS1 client
12+
config = {
13+
"endpoint": "https://api.nsone.net",
14+
"default_key": "test1",
15+
"keys": {
16+
"test1": {
17+
"key": os.environ.get("NS1_APIKEY", "test1"),
18+
"desc": "test key",
19+
}
20+
},
21+
}
22+
23+
# Create a config from dictionary and create the client
24+
c = Config()
25+
c.loadFromDict(config)
26+
client = NS1(config=c)
27+
28+
# If no real API key is set, we'll get appropriate errors
29+
# This is just an example to show the usage pattern
30+
if not os.environ.get("NS1_APIKEY"):
31+
print("Using a mock endpoint - for real usage, set the NS1_APIKEY environment variable")
32+
33+
34+
# Usage Alerts API Examples
35+
def usage_alerts_example():
36+
print("\n=== Usage Alerts Examples ===\n")
37+
38+
# List all usage alerts
39+
print("Listing usage alerts:")
40+
try:
41+
alerts = client.alerts().usage.list(limit=10)
42+
print(f"Total alerts: {alerts.get('total_results', 0)}")
43+
for i, alert in enumerate(alerts.get("results", [])):
44+
print(f" {i+1}. {alert.get('name')} (id: {alert.get('id')})")
45+
except Exception as e:
46+
print(f"Error listing alerts: {e}")
47+
48+
# Create a usage alert
49+
print("\nCreating a usage alert:")
50+
try:
51+
alert = client.alerts().usage.create(
52+
name="Example query usage alert",
53+
subtype="query_usage",
54+
alert_at_percent=85,
55+
notifier_list_ids=[],
56+
zone_names=[],
57+
)
58+
alert_id = alert["id"]
59+
print(f"Created alert: {alert['name']} (id: {alert_id})")
60+
print(f"Alert details: {json.dumps(alert, indent=2)}")
61+
except Exception as e:
62+
print(f"Error creating alert: {e}")
63+
return
64+
65+
# Update the alert
66+
print("\nUpdating the alert threshold to 90%:")
67+
try:
68+
updated = client.alerts().usage.patch(alert_id, alert_at_percent=90)
69+
print(f"Updated alert: {updated['name']}")
70+
print(f"New threshold: {updated['data']['alert_at_percent']}%")
71+
except Exception as e:
72+
print(f"Error updating alert: {e}")
73+
74+
# Get alert details
75+
print("\nGetting alert details:")
76+
try:
77+
details = client.alerts().usage.get(alert_id)
78+
print(f"Alert details: {json.dumps(details, indent=2)}")
79+
except Exception as e:
80+
print(f"Error getting alert: {e}")
81+
82+
# Delete the alert
83+
print("\nDeleting the alert:")
84+
try:
85+
client.alerts().usage.delete(alert_id)
86+
print(f"Alert {alert_id} deleted successfully")
87+
except Exception as e:
88+
print(f"Error deleting alert: {e}")
89+
90+
91+
# Test validation failures
92+
def test_validation():
93+
print("\n=== Validation Tests ===\n")
94+
95+
# Test invalid subtype
96+
print("Testing invalid subtype:")
97+
try:
98+
client.alerts().usage.create(
99+
name="Test alert", subtype="invalid_subtype", alert_at_percent=85
100+
)
101+
except ValueError as e:
102+
print(f"Validation error (expected): {e}")
103+
104+
# Test threshold too low
105+
print("\nTesting threshold too low (0):")
106+
try:
107+
client.alerts().usage.create(
108+
name="Test alert", subtype="query_usage", alert_at_percent=0
109+
)
110+
except ValueError as e:
111+
print(f"Validation error (expected): {e}")
112+
113+
# Test threshold too high
114+
print("\nTesting threshold too high (101):")
115+
try:
116+
client.alerts().usage.create(
117+
name="Test alert", subtype="query_usage", alert_at_percent=101
118+
)
119+
except ValueError as e:
120+
print(f"Validation error (expected): {e}")
121+
122+
123+
if __name__ == "__main__":
124+
print("Usage Alerts API Examples")
125+
print("-" * 30)
126+
print(
127+
"Note: To run against the actual API, set the NS1_APIKEY environment variable"
128+
)
129+
print("Otherwise, this will run against a mock API endpoint")
130+
131+
# Run examples
132+
usage_alerts_example()
133+
test_validation()

ns1/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
#
66
from .config import Config
77

8-
version = "0.24.0"
8+
version = "0.25.0"
99

1010

1111
class NS1:
@@ -242,6 +242,7 @@ def alerts(self):
242242

243243
return ns1.rest.alerts.Alerts(self.config)
244244

245+
245246
def billing_usage(self):
246247
"""
247248
Return a new raw REST interface to BillingUsage resources

ns1/alerting/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .usage_alerts import UsageAlertsAPI, USAGE_SUBTYPES
2+
3+
__all__ = ["UsageAlertsAPI", "USAGE_SUBTYPES"]

ns1/alerting/usage_alerts.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from typing import List, Optional, Dict, Any
2+
3+
USAGE_SUBTYPES = {
4+
"query_usage",
5+
"record_usage",
6+
"china_query_usage",
7+
"rum_decision_usage",
8+
"filter_chain_usage",
9+
"monitor_usage",
10+
}
11+
12+
13+
def _validate(name: str, subtype: str, alert_at_percent: int) -> None:
14+
if not name:
15+
raise ValueError("name required")
16+
if subtype not in USAGE_SUBTYPES:
17+
raise ValueError("invalid subtype")
18+
if not isinstance(alert_at_percent, int) or not (
19+
1 <= alert_at_percent <= 100
20+
):
21+
raise ValueError("data.alert_at_percent must be int in 1..100")
22+
23+
24+
class UsageAlertsAPI:
25+
"""
26+
Account-scoped usage alerts. Triggers when usage ≥ alert_at_percent.
27+
28+
Server rules:
29+
- Always type='account'
30+
- data.alert_at_percent must be in 1..100
31+
- PATCH must not include type/subtype
32+
- zone_names/notifier_list_ids may be empty ([])
33+
- Server ignores datafeed notifiers for usage alerts
34+
"""
35+
36+
def __init__(self, client) -> None:
37+
self._c = client
38+
39+
def create(
40+
self,
41+
*,
42+
name: str,
43+
subtype: str,
44+
alert_at_percent: int,
45+
notifier_list_ids: Optional[List[str]] = None,
46+
zone_names: Optional[List[str]] = None,
47+
) -> Dict[str, Any]:
48+
_validate(name, subtype, alert_at_percent)
49+
body = {
50+
"name": name,
51+
"type": "account",
52+
"subtype": subtype,
53+
"data": {"alert_at_percent": int(alert_at_percent)},
54+
"notifier_list_ids": notifier_list_ids or [],
55+
"zone_names": zone_names or [],
56+
}
57+
return self._c._post("/alerting/v1/alerts", json=body)
58+
59+
def get(self, alert_id: str) -> Dict[str, Any]:
60+
return self._c._get(f"/alerting/v1/alerts/{alert_id}")
61+
62+
def patch(
63+
self,
64+
alert_id: str,
65+
*,
66+
name: Optional[str] = None,
67+
alert_at_percent: Optional[int] = None,
68+
notifier_list_ids: Optional[List[str]] = None,
69+
zone_names: Optional[List[str]] = None,
70+
) -> Dict[str, Any]:
71+
patch: Dict[str, Any] = {}
72+
if name is not None:
73+
patch["name"] = name
74+
if alert_at_percent is not None:
75+
if not isinstance(alert_at_percent, int) or not (
76+
1 <= alert_at_percent <= 100
77+
):
78+
raise ValueError("data.alert_at_percent must be int in 1..100")
79+
patch["data"] = {"alert_at_percent": int(alert_at_percent)}
80+
if notifier_list_ids is not None:
81+
patch["notifier_list_ids"] = notifier_list_ids
82+
if zone_names is not None:
83+
patch["zone_names"] = zone_names
84+
return self._c._patch(f"/alerting/v1/alerts/{alert_id}", json=patch)
85+
86+
def delete(self, alert_id: str) -> None:
87+
self._c._delete(f"/alerting/v1/alerts/{alert_id}")
88+
89+
def list(
90+
self,
91+
*,
92+
limit: int = 50,
93+
next: Optional[str] = None,
94+
order_descending: bool = False,
95+
) -> Dict[str, Any]:
96+
params: Dict[str, Any] = {"limit": limit}
97+
if next:
98+
params["next"] = next
99+
if order_descending:
100+
params["order_descending"] = "true"
101+
return self._c._get("/alerting/v1/alerts", params=params)

ns1/rest/alerts.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
# License under The MIT License (MIT). See LICENSE in project root.
55
#
66
from . import resource
7+
from ns1.alerting import UsageAlertsAPI
78

89

910
class Alerts(resource.BaseResource):
@@ -15,6 +16,62 @@ class Alerts(resource.BaseResource):
1516
"record_ids",
1617
"zone_names",
1718
]
19+
20+
# Forward HTTP methods needed by UsageAlertsAPI
21+
def _get(self, path, params=None):
22+
"""Forward GET requests to make_request"""
23+
# Fix path to start with /alerting/v1/ if needed
24+
if path.startswith('/'):
25+
path = path[1:] # Remove leading slash
26+
if not path.startswith("alerting/v1/"):
27+
# Alerting endpoints should have this prefix
28+
path = f"{self.ROOT}/{path.split('/')[-1]}"
29+
return self._make_request("GET", path, params=params)
30+
31+
def _post(self, path, json=None):
32+
"""Forward POST requests to make_request"""
33+
if path.startswith('/'):
34+
path = path[1:] # Remove leading slash
35+
if not path.startswith("alerting/v1/"):
36+
path = f"{self.ROOT}"
37+
return self._make_request("POST", path, body=json)
38+
39+
def _patch(self, path, json=None):
40+
"""Forward PATCH requests to make_request"""
41+
if path.startswith('/'):
42+
path = path[1:] # Remove leading slash
43+
if not path.startswith("alerting/v1/"):
44+
parts = path.split('/')
45+
path = f"{self.ROOT}/{parts[-1]}"
46+
return self._make_request("PATCH", path, body=json)
47+
48+
def _delete(self, path):
49+
"""Forward DELETE requests to make_request"""
50+
if path.startswith('/'):
51+
path = path[1:] # Remove leading slash
52+
if not path.startswith("alerting/v1/"):
53+
parts = path.split('/')
54+
path = f"{self.ROOT}/{parts[-1]}"
55+
return self._make_request("DELETE", path)
56+
57+
def __init__(self, config):
58+
super(Alerts, self).__init__(config)
59+
self._usage_api = None
60+
61+
@property
62+
def usage(self):
63+
"""
64+
Return interface to usage alerts operations
65+
66+
:return: :py:class:`ns1.alerting.UsageAlertsAPI`
67+
"""
68+
if self._usage_api is None:
69+
# The UsageAlertsAPI expects a client with HTTP methods (_get, _post, etc.)
70+
# Since the NS1 object is not directly accessible here, we'll use self as the client
71+
# The UsageAlertsAPI only needs HTTP methods (_get, _post, etc.)
72+
# For tests, we'll later patch the _c attribute on the UsageAlertsAPI instance
73+
self._usage_api = UsageAlertsAPI(self)
74+
return self._usage_api
1875

1976
def _buildBody(self, alid, **kwargs):
2077
body = {}

0 commit comments

Comments
 (0)