kpi-engine 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kpi_engine-1.0.0/LICENSE +21 -0
- kpi_engine-1.0.0/PKG-INFO +28 -0
- kpi_engine-1.0.0/README.md +401 -0
- kpi_engine-1.0.0/kpi_engine/__init__.py +16 -0
- kpi_engine-1.0.0/kpi_engine/alerts/__init__.py +7 -0
- kpi_engine-1.0.0/kpi_engine/alerts/dispatcher.py +16 -0
- kpi_engine-1.0.0/kpi_engine/alerts/email.py +46 -0
- kpi_engine-1.0.0/kpi_engine/alerts/evaluator.py +30 -0
- kpi_engine-1.0.0/kpi_engine/alerts/pagerduty.py +30 -0
- kpi_engine-1.0.0/kpi_engine/alerts/slack.py +20 -0
- kpi_engine-1.0.0/kpi_engine/audit.py +102 -0
- kpi_engine-1.0.0/kpi_engine/backends/__init__.py +6 -0
- kpi_engine-1.0.0/kpi_engine/backends/base.py +11 -0
- kpi_engine-1.0.0/kpi_engine/backends/dataframe.py +73 -0
- kpi_engine-1.0.0/kpi_engine/backends/derived.py +16 -0
- kpi_engine-1.0.0/kpi_engine/backends/sql.py +33 -0
- kpi_engine-1.0.0/kpi_engine/comparator.py +29 -0
- kpi_engine-1.0.0/kpi_engine/engine.py +151 -0
- kpi_engine-1.0.0/kpi_engine/models.py +79 -0
- kpi_engine-1.0.0/kpi_engine/period.py +68 -0
- kpi_engine-1.0.0/kpi_engine/registry.py +34 -0
- kpi_engine-1.0.0/kpi_engine/scheduler.py +56 -0
- kpi_engine-1.0.0/kpi_engine/server.py +79 -0
- kpi_engine-1.0.0/kpi_engine.egg-info/PKG-INFO +28 -0
- kpi_engine-1.0.0/kpi_engine.egg-info/SOURCES.txt +32 -0
- kpi_engine-1.0.0/kpi_engine.egg-info/dependency_links.txt +1 -0
- kpi_engine-1.0.0/kpi_engine.egg-info/requires.txt +27 -0
- kpi_engine-1.0.0/kpi_engine.egg-info/top_level.txt +1 -0
- kpi_engine-1.0.0/pyproject.toml +33 -0
- kpi_engine-1.0.0/setup.cfg +4 -0
- kpi_engine-1.0.0/tests/test_alerts.py +109 -0
- kpi_engine-1.0.0/tests/test_comparator.py +76 -0
- kpi_engine-1.0.0/tests/test_engine.py +120 -0
- kpi_engine-1.0.0/tests/test_period.py +83 -0
kpi_engine-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 muhammadsufiyanbaig
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kpi-engine
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Declarative KPI computation and alerting framework
|
|
5
|
+
Requires-Python: >=3.8
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Requires-Dist: pandas>=1.3
|
|
8
|
+
Requires-Dist: sqlalchemy>=1.4
|
|
9
|
+
Requires-Dist: jinja2>=3.0
|
|
10
|
+
Requires-Dist: pydantic>=2.0
|
|
11
|
+
Requires-Dist: pyyaml>=6.0
|
|
12
|
+
Requires-Dist: numpy>=1.21
|
|
13
|
+
Requires-Dist: python-dateutil>=2.8
|
|
14
|
+
Provides-Extra: alerts
|
|
15
|
+
Requires-Dist: requests>=2.28; extra == "alerts"
|
|
16
|
+
Provides-Extra: server
|
|
17
|
+
Requires-Dist: fastapi>=0.100; extra == "server"
|
|
18
|
+
Requires-Dist: uvicorn>=0.22; extra == "server"
|
|
19
|
+
Provides-Extra: scheduler
|
|
20
|
+
Requires-Dist: croniter>=1.3; extra == "scheduler"
|
|
21
|
+
Provides-Extra: bigquery
|
|
22
|
+
Requires-Dist: google-cloud-bigquery>=3.0; extra == "bigquery"
|
|
23
|
+
Provides-Extra: snowflake
|
|
24
|
+
Requires-Dist: snowflake-connector-python>=3.0; extra == "snowflake"
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
# kpi-engine
|
|
2
|
+
|
|
3
|
+
> **A declarative framework for defining, computing, and alerting on KPIs from SQL or DataFrames — with built-in period-over-period comparisons.**
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/kpi-engine/)
|
|
6
|
+
[](https://pypi.org/project/kpi-engine/)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Overview
|
|
12
|
+
|
|
13
|
+
`kpi-engine` brings structure and repeatability to business metrics. Instead of writing ad-hoc SQL queries and notebook cells to compute KPIs, you define them once in a declarative YAML or Python DSL — then `kpi-engine` handles computation, historical comparisons, trend analysis, and alerting automatically.
|
|
14
|
+
|
|
15
|
+
**Supported backends:** PostgreSQL, MySQL, SQLite, BigQuery, Snowflake (via SQLAlchemy) and pandas DataFrames.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install kpi-engine
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
With optional extras:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install "kpi-engine[alerts]" # Slack, email, PagerDuty
|
|
29
|
+
pip install "kpi-engine[server]" # FastAPI REST server
|
|
30
|
+
pip install "kpi-engine[scheduler]" # Cron scheduling
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Quick Start
|
|
36
|
+
|
|
37
|
+
### From a YAML file
|
|
38
|
+
|
|
39
|
+
```yaml
|
|
40
|
+
# kpis.yaml
|
|
41
|
+
kpis:
|
|
42
|
+
- name: monthly_revenue
|
|
43
|
+
label: Monthly Revenue
|
|
44
|
+
source: sql
|
|
45
|
+
query: >
|
|
46
|
+
SELECT SUM(amount) FROM orders
|
|
47
|
+
WHERE order_date >= '{{ period_start }}'::date
|
|
48
|
+
AND order_date < '{{ period_end }}'::date
|
|
49
|
+
aggregation: sum
|
|
50
|
+
unit: USD
|
|
51
|
+
compare: [MoM, YoY]
|
|
52
|
+
alerts:
|
|
53
|
+
- condition: "< 100000"
|
|
54
|
+
severity: critical
|
|
55
|
+
message: Revenue dropped below $100K
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from sqlalchemy import create_engine
|
|
60
|
+
from kpi_engine import KPIEngine
|
|
61
|
+
|
|
62
|
+
engine = KPIEngine.from_yaml(
|
|
63
|
+
"kpis.yaml",
|
|
64
|
+
connection=create_engine("postgresql://user:pass@host/db")
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
results = engine.run(period="last_month")
|
|
68
|
+
|
|
69
|
+
for kpi in results:
|
|
70
|
+
print(f"{kpi.label}: {kpi.value:,.2f} {kpi.unit}")
|
|
71
|
+
if "MoM" in kpi.comparisons:
|
|
72
|
+
print(f" MoM: {kpi.mom_change_pct:+.1f}%")
|
|
73
|
+
print(f" Status: {kpi.alert_status}")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### From Python directly
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from kpi_engine import KPIEngine
|
|
80
|
+
from kpi_engine.models import KPIDefinition, Alert
|
|
81
|
+
import pandas as pd
|
|
82
|
+
|
|
83
|
+
df = pd.DataFrame({
|
|
84
|
+
"revenue": [1000, 2000, 3000],
|
|
85
|
+
"order_date": pd.to_datetime(["2024-11-01", "2024-11-15", "2024-11-28"]),
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
kpis = [
|
|
89
|
+
KPIDefinition(
|
|
90
|
+
name="revenue",
|
|
91
|
+
label="Monthly Revenue",
|
|
92
|
+
source="dataframe",
|
|
93
|
+
aggregation="sum",
|
|
94
|
+
unit="USD",
|
|
95
|
+
query="orders.revenue", # "table.column" format
|
|
96
|
+
compare=["MoM"],
|
|
97
|
+
alerts=[Alert(condition="< 1000", severity="warning")],
|
|
98
|
+
)
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
engine = KPIEngine(kpis=kpis, dataframes={"orders": df})
|
|
102
|
+
results = engine.run(period="2024-11")
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## How It Works
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
KPI Definitions (YAML or Python DSL)
|
|
111
|
+
│
|
|
112
|
+
▼
|
|
113
|
+
┌─────────────────────────────┐
|
|
114
|
+
│ KPI Registry │ ← Parses and validates all KPI definitions
|
|
115
|
+
└──────────────┬──────────────┘
|
|
116
|
+
▼
|
|
117
|
+
┌─────────────────────────────┐
|
|
118
|
+
│ Period Resolver │ ← Converts "last_month", "2024-Q3", "yesterday"
|
|
119
|
+
│ │ into concrete start/end datetime pairs
|
|
120
|
+
└──────────────┬──────────────┘
|
|
121
|
+
▼
|
|
122
|
+
┌─────────────────────────────────────────────────────┐
|
|
123
|
+
│ Computation Engine │
|
|
124
|
+
│ ┌──────────────┐ ┌──────────────┐ Derived KPI │
|
|
125
|
+
│ │ SQL Backend │ │ DataFrame │ (expression) │
|
|
126
|
+
│ │ (SQLAlchemy) │ │ Backend │ │
|
|
127
|
+
│ └──────────────┘ └──────────────┘ │
|
|
128
|
+
└──────────────┬──────────────────────────────────────┘
|
|
129
|
+
▼
|
|
130
|
+
┌─────────────────────────────┐
|
|
131
|
+
│ Period-over-Period Comparator│ ← Computes Δ and Δ%
|
|
132
|
+
└──────────────┬──────────────┘
|
|
133
|
+
▼
|
|
134
|
+
┌─────────────────────────────┐
|
|
135
|
+
│ Alert Evaluator │ ← Threshold, change %, anomaly rules
|
|
136
|
+
└──────────────┬──────────────┘
|
|
137
|
+
▼
|
|
138
|
+
┌─────────────────────────────┐
|
|
139
|
+
│ Alert Dispatcher │ ← Slack, email, PagerDuty, webhooks
|
|
140
|
+
└──────────────┬──────────────┘
|
|
141
|
+
▼
|
|
142
|
+
┌─────────────────────────────┐
|
|
143
|
+
│ KPIResult + Audit Log │ ← Structured result + CSV/SQLite history
|
|
144
|
+
└─────────────────────────────┘
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Period Strings
|
|
150
|
+
|
|
151
|
+
| Input | Resolves To |
|
|
152
|
+
|-------|------------|
|
|
153
|
+
| `"yesterday"` | Previous calendar day |
|
|
154
|
+
| `"last_week"` | Mon–Sun of the previous week |
|
|
155
|
+
| `"last_month"` | Full previous calendar month |
|
|
156
|
+
| `"last_quarter"` | Previous Q1/Q2/Q3/Q4 |
|
|
157
|
+
| `"2024-Q3"` | July 1 – September 30, 2024 |
|
|
158
|
+
| `"2024-11"` | All of November 2024 |
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## KPI Sources
|
|
163
|
+
|
|
164
|
+
### SQL Backend
|
|
165
|
+
|
|
166
|
+
Queries run via SQLAlchemy. Use Jinja2 template variables `{{ period_start }}` and `{{ period_end }}` in your query:
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
KPIDefinition(
|
|
170
|
+
name="signups",
|
|
171
|
+
label="New Signups",
|
|
172
|
+
source="sql",
|
|
173
|
+
aggregation="count",
|
|
174
|
+
query="SELECT COUNT(*) FROM users WHERE created_at >= '{{ period_start }}'",
|
|
175
|
+
)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### DataFrame Backend
|
|
179
|
+
|
|
180
|
+
Pass a dict of DataFrames. Use `"table.column"` in the `query` field:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
KPIDefinition(
|
|
184
|
+
name="revenue",
|
|
185
|
+
source="dataframe",
|
|
186
|
+
aggregation="sum",
|
|
187
|
+
query="sales.amount", # sales DataFrame, amount column
|
|
188
|
+
)
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Aggregations: `sum`, `avg`, `count`, `last`, `rate`
|
|
192
|
+
|
|
193
|
+
### Derived KPIs
|
|
194
|
+
|
|
195
|
+
Computed from already-resolved KPI values using a Python expression:
|
|
196
|
+
|
|
197
|
+
```python
|
|
198
|
+
KPIDefinition(
|
|
199
|
+
name="arpu",
|
|
200
|
+
label="ARPU",
|
|
201
|
+
source="derived",
|
|
202
|
+
expression="revenue / active_users",
|
|
203
|
+
unit="USD",
|
|
204
|
+
)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Derived KPIs always run after their dependencies. The engine builds a DAG automatically.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## Alerts
|
|
212
|
+
|
|
213
|
+
### Condition syntax
|
|
214
|
+
|
|
215
|
+
| Condition | Triggers when |
|
|
216
|
+
|-----------|--------------|
|
|
217
|
+
| `"< 1000"` | value is below 1000 |
|
|
218
|
+
| `"> 0.15"` | value is above 0.15 |
|
|
219
|
+
| `"<= 100"` | value is at most 100 |
|
|
220
|
+
| `">= 500"` | value is at least 500 |
|
|
221
|
+
| `"== 0"` | value equals 0 |
|
|
222
|
+
|
|
223
|
+
### Alert channels
|
|
224
|
+
|
|
225
|
+
**Slack:**
|
|
226
|
+
```python
|
|
227
|
+
from kpi_engine.alerts import SlackChannel
|
|
228
|
+
|
|
229
|
+
engine = KPIEngine(
|
|
230
|
+
kpis=kpis,
|
|
231
|
+
alert_channels=[SlackChannel(webhook_url="https://hooks.slack.com/...")]
|
|
232
|
+
)
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
**Email:**
|
|
236
|
+
```python
|
|
237
|
+
from kpi_engine.alerts import EmailChannel
|
|
238
|
+
|
|
239
|
+
EmailChannel(
|
|
240
|
+
smtp_host="smtp.gmail.com", smtp_port=587,
|
|
241
|
+
from_email="alerts@company.com",
|
|
242
|
+
to_emails=["team@company.com"],
|
|
243
|
+
username="alerts@company.com", password="..."
|
|
244
|
+
)
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
**PagerDuty:**
|
|
248
|
+
```python
|
|
249
|
+
from kpi_engine.alerts import PagerDutyChannel
|
|
250
|
+
|
|
251
|
+
PagerDutyChannel(integration_key="your-integration-key")
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## REST API
|
|
257
|
+
|
|
258
|
+
```python
|
|
259
|
+
pip install "kpi-engine[server]"
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
engine.serve(port=8000)
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
| Endpoint | Description |
|
|
267
|
+
|----------|-------------|
|
|
268
|
+
| `GET /kpis?period=last_month` | Compute all KPIs |
|
|
269
|
+
| `GET /kpis/{name}?period=2024-11` | Compute a single KPI |
|
|
270
|
+
| `GET /kpis/{name}/history?n=10` | Last n results |
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
## Scheduling
|
|
275
|
+
|
|
276
|
+
```python
|
|
277
|
+
pip install "kpi-engine[scheduler]"
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
```python
|
|
281
|
+
scheduler = engine.schedule(
|
|
282
|
+
cron="0 9 1 * *", # 1st of every month at 9am UTC
|
|
283
|
+
period_fn=lambda: "last_month",
|
|
284
|
+
callback=lambda results: print(f"Done: {len(results)} KPIs")
|
|
285
|
+
)
|
|
286
|
+
# runs in a background daemon thread
|
|
287
|
+
# scheduler.stop() to cancel
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## Audit Log
|
|
293
|
+
|
|
294
|
+
```python
|
|
295
|
+
engine = KPIEngine(kpis=kpis, connection=conn, audit_log="audit.csv")
|
|
296
|
+
# or
|
|
297
|
+
engine = KPIEngine(kpis=kpis, connection=conn, audit_log="audit.db") # SQLite
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Every `engine.run()` call appends results to the audit log automatically.
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## API Reference
|
|
305
|
+
|
|
306
|
+
### `KPIEngine`
|
|
307
|
+
|
|
308
|
+
```python
|
|
309
|
+
KPIEngine(
|
|
310
|
+
kpis: list[KPIDefinition],
|
|
311
|
+
connection=None, # SQLAlchemy engine
|
|
312
|
+
dataframes: dict = None, # {"table_name": pd.DataFrame}
|
|
313
|
+
alert_channels: list = None,
|
|
314
|
+
audit_log: str = None # path to .csv or .db file
|
|
315
|
+
)
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
| Method | Returns | Description |
|
|
319
|
+
|--------|---------|-------------|
|
|
320
|
+
| `engine.run(period)` | `list[KPIResult]` | Compute all KPIs |
|
|
321
|
+
| `engine.run_kpi(name, period)` | `KPIResult` | Compute one KPI |
|
|
322
|
+
| `engine.history(name, n)` | `list[KPIResult]` | Last n results |
|
|
323
|
+
| `engine.schedule(cron, period_fn)` | `KPIScheduler` | Schedule recurring runs |
|
|
324
|
+
| `engine.serve(port)` | — | Start REST API (blocking) |
|
|
325
|
+
| `KPIEngine.from_yaml(path, ...)` | `KPIEngine` | Load from YAML config |
|
|
326
|
+
|
|
327
|
+
### `KPIResult`
|
|
328
|
+
|
|
329
|
+
```python
|
|
330
|
+
result.value # float
|
|
331
|
+
result.unit # str
|
|
332
|
+
result.alert_status # "ok" | "warning" | "critical"
|
|
333
|
+
result.comparisons # dict[str, ComparisonResult]
|
|
334
|
+
result.alerts_triggered # list[AlertResult]
|
|
335
|
+
result.mom_change_pct # float | None
|
|
336
|
+
result.yoy_change_pct # float | None
|
|
337
|
+
result.period_start # datetime
|
|
338
|
+
result.period_end # datetime
|
|
339
|
+
result.query_duration_ms # float
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
### `KPIDefinition` fields
|
|
343
|
+
|
|
344
|
+
| Field | Type | Description |
|
|
345
|
+
|-------|------|-------------|
|
|
346
|
+
| `name` | `str` | Unique identifier |
|
|
347
|
+
| `label` | `str` | Human-readable name |
|
|
348
|
+
| `source` | `str` | `"sql"` \| `"dataframe"` \| `"derived"` |
|
|
349
|
+
| `aggregation` | `str` | `"sum"` \| `"avg"` \| `"count"` \| `"rate"` \| `"last"` |
|
|
350
|
+
| `query` | `str` | SQL template or `"table.column"` |
|
|
351
|
+
| `expression` | `str` | Python expression for derived KPIs |
|
|
352
|
+
| `compare` | `list[str]` | `["MoM", "YoY", "QoQ", "WoW", "DoD"]` |
|
|
353
|
+
| `polarity` | `str` | `"higher_is_better"` \| `"lower_is_better"` |
|
|
354
|
+
| `alerts` | `list[Alert]` | Alert definitions |
|
|
355
|
+
| `unit` | `str` | Display unit (e.g. `"USD"`, `"%"`) |
|
|
356
|
+
|
|
357
|
+
---
|
|
358
|
+
|
|
359
|
+
## Project Structure
|
|
360
|
+
|
|
361
|
+
```
|
|
362
|
+
kpi-engine/
|
|
363
|
+
├── kpi_engine/
|
|
364
|
+
│ ├── engine.py # KPIEngine orchestrator
|
|
365
|
+
│ ├── registry.py # KPI registry and validation
|
|
366
|
+
│ ├── models.py # KPIDefinition, KPIResult, Alert dataclasses
|
|
367
|
+
│ ├── period.py # Period resolution logic
|
|
368
|
+
│ ├── backends/
|
|
369
|
+
│ │ ├── base.py # BaseBackend abstract class
|
|
370
|
+
│ │ ├── sql.py # SQLAlchemy backend
|
|
371
|
+
│ │ ├── dataframe.py # Pandas backend
|
|
372
|
+
│ │ └── derived.py # Derived KPI expression evaluator
|
|
373
|
+
│ ├── comparator.py # Period-over-period comparison
|
|
374
|
+
│ ├── alerts/
|
|
375
|
+
│ │ ├── evaluator.py # Alert threshold evaluation
|
|
376
|
+
│ │ ├── dispatcher.py # Routes alerts to channels
|
|
377
|
+
│ │ ├── slack.py # Slack webhook channel
|
|
378
|
+
│ │ ├── email.py # SMTP email channel
|
|
379
|
+
│ │ └── pagerduty.py # PagerDuty Events API channel
|
|
380
|
+
│ ├── scheduler.py # Cron-based scheduling
|
|
381
|
+
│ ├── audit.py # Audit log (CSV or SQLite)
|
|
382
|
+
│ └── server.py # FastAPI REST server
|
|
383
|
+
└── tests/
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
---
|
|
387
|
+
|
|
388
|
+
## License
|
|
389
|
+
|
|
390
|
+
MIT — see [LICENSE](LICENSE)
|
|
391
|
+
|
|
392
|
+
---
|
|
393
|
+
|
|
394
|
+
## Contributing
|
|
395
|
+
|
|
396
|
+
PRs welcome. Add tests for new KPI types and alert conditions.
|
|
397
|
+
|
|
398
|
+
```bash
|
|
399
|
+
pip install -e ".[dev]"
|
|
400
|
+
pytest tests/ -v
|
|
401
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .engine import KPIEngine
|
|
2
|
+
from .models import Alert, AlertResult, ComparisonResult, KPIDefinition, KPIResult
|
|
3
|
+
from .period import Period, resolve_period
|
|
4
|
+
from .registry import KPIRegistry
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"KPIEngine",
|
|
8
|
+
"KPIDefinition",
|
|
9
|
+
"KPIResult",
|
|
10
|
+
"Alert",
|
|
11
|
+
"AlertResult",
|
|
12
|
+
"ComparisonResult",
|
|
13
|
+
"Period",
|
|
14
|
+
"resolve_period",
|
|
15
|
+
"KPIRegistry",
|
|
16
|
+
]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from .evaluator import AlertEvaluator
|
|
2
|
+
from .dispatcher import AlertDispatcher
|
|
3
|
+
from .slack import SlackChannel
|
|
4
|
+
from .email import EmailChannel
|
|
5
|
+
from .pagerduty import PagerDutyChannel
|
|
6
|
+
|
|
7
|
+
__all__ = ["AlertEvaluator", "AlertDispatcher", "SlackChannel", "EmailChannel", "PagerDutyChannel"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
|
|
3
|
+
logger = logging.getLogger(__name__)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AlertDispatcher:
|
|
7
|
+
def __init__(self, channels: list):
|
|
8
|
+
self.channels = channels
|
|
9
|
+
|
|
10
|
+
def dispatch(self, kpi, value, triggered_alerts):
|
|
11
|
+
for alert_result in triggered_alerts:
|
|
12
|
+
for channel in self.channels:
|
|
13
|
+
try:
|
|
14
|
+
channel.send(kpi, value, alert_result)
|
|
15
|
+
except Exception as e:
|
|
16
|
+
logger.warning("Failed to dispatch alert via %s: %s", channel.__class__.__name__, e)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import smtplib
|
|
2
|
+
from email.mime.multipart import MIMEMultipart
|
|
3
|
+
from email.mime.text import MIMEText
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class EmailChannel:
|
|
8
|
+
def __init__(
|
|
9
|
+
self,
|
|
10
|
+
smtp_host: str,
|
|
11
|
+
smtp_port: int,
|
|
12
|
+
from_email: str,
|
|
13
|
+
to_emails: List[str],
|
|
14
|
+
username: str = None,
|
|
15
|
+
password: str = None,
|
|
16
|
+
use_tls: bool = True,
|
|
17
|
+
):
|
|
18
|
+
self.smtp_host = smtp_host
|
|
19
|
+
self.smtp_port = smtp_port
|
|
20
|
+
self.from_email = from_email
|
|
21
|
+
self.to_emails = to_emails
|
|
22
|
+
self.username = username
|
|
23
|
+
self.password = password
|
|
24
|
+
self.use_tls = use_tls
|
|
25
|
+
|
|
26
|
+
def send(self, kpi, value, alert_result):
|
|
27
|
+
msg = MIMEMultipart()
|
|
28
|
+
msg["From"] = self.from_email
|
|
29
|
+
msg["To"] = ", ".join(self.to_emails)
|
|
30
|
+
msg["Subject"] = f"KPI Alert [{alert_result.severity.upper()}]: {kpi.label}"
|
|
31
|
+
|
|
32
|
+
body = (
|
|
33
|
+
f"KPI Alert triggered.\n\n"
|
|
34
|
+
f"KPI: {kpi.label}\n"
|
|
35
|
+
f"Value: {value} {kpi.unit}\n"
|
|
36
|
+
f"Severity: {alert_result.severity}\n"
|
|
37
|
+
f"Message: {alert_result.message}\n"
|
|
38
|
+
)
|
|
39
|
+
msg.attach(MIMEText(body, "plain"))
|
|
40
|
+
|
|
41
|
+
with smtplib.SMTP(self.smtp_host, self.smtp_port) as server:
|
|
42
|
+
if self.use_tls:
|
|
43
|
+
server.starttls()
|
|
44
|
+
if self.username and self.password:
|
|
45
|
+
server.login(self.username, self.password)
|
|
46
|
+
server.sendmail(self.from_email, self.to_emails, msg.as_string())
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from ..models import Alert, AlertResult
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AlertEvaluator:
|
|
5
|
+
def evaluate(self, value: float, alerts: list) -> list:
|
|
6
|
+
results = []
|
|
7
|
+
for alert in alerts:
|
|
8
|
+
triggered = self._check_condition(value, alert.condition)
|
|
9
|
+
results.append(AlertResult(
|
|
10
|
+
alert=alert,
|
|
11
|
+
triggered=triggered,
|
|
12
|
+
message=alert.message or f"KPI value {value} {alert.condition}",
|
|
13
|
+
severity=alert.severity
|
|
14
|
+
))
|
|
15
|
+
return results
|
|
16
|
+
|
|
17
|
+
def _check_condition(self, value: float, condition: str) -> bool:
|
|
18
|
+
"""Evaluate a condition string like '< 50000' or '> 0.15'."""
|
|
19
|
+
condition = condition.strip()
|
|
20
|
+
if condition.startswith("<= "):
|
|
21
|
+
return value <= float(condition[3:])
|
|
22
|
+
if condition.startswith(">= "):
|
|
23
|
+
return value >= float(condition[3:])
|
|
24
|
+
if condition.startswith("== "):
|
|
25
|
+
return value == float(condition[3:])
|
|
26
|
+
if condition.startswith("< "):
|
|
27
|
+
return value < float(condition[2:])
|
|
28
|
+
if condition.startswith("> "):
|
|
29
|
+
return value > float(condition[2:])
|
|
30
|
+
return False
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
|
|
3
|
+
_SEVERITY_MAP = {"info": "info", "warning": "warning", "critical": "critical"}
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PagerDutyChannel:
|
|
7
|
+
API_URL = "https://events.pagerduty.com/v2/enqueue"
|
|
8
|
+
|
|
9
|
+
def __init__(self, integration_key: str):
|
|
10
|
+
self.integration_key = integration_key
|
|
11
|
+
|
|
12
|
+
def send(self, kpi, value, alert_result):
|
|
13
|
+
payload = {
|
|
14
|
+
"routing_key": self.integration_key,
|
|
15
|
+
"event_action": "trigger",
|
|
16
|
+
"payload": {
|
|
17
|
+
"summary": f"KPI Alert: {kpi.label} — {alert_result.message}",
|
|
18
|
+
"severity": _SEVERITY_MAP.get(alert_result.severity, "warning"),
|
|
19
|
+
"source": "kpi-engine",
|
|
20
|
+
"custom_details": {
|
|
21
|
+
"kpi_name": kpi.name,
|
|
22
|
+
"kpi_label": kpi.label,
|
|
23
|
+
"value": value,
|
|
24
|
+
"unit": kpi.unit,
|
|
25
|
+
"condition": alert_result.alert.condition,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
response = requests.post(self.API_URL, json=payload, timeout=10)
|
|
30
|
+
response.raise_for_status()
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SlackChannel:
|
|
5
|
+
def __init__(self, webhook_url: str, channel: str = None):
|
|
6
|
+
self.webhook_url = webhook_url
|
|
7
|
+
self.channel = channel
|
|
8
|
+
|
|
9
|
+
def send(self, kpi, value, alert_result):
|
|
10
|
+
message = {
|
|
11
|
+
"text": (
|
|
12
|
+
f"*KPI Alert* [{alert_result.severity.upper()}]\n"
|
|
13
|
+
f"*{kpi.label}*: {value} {kpi.unit}\n"
|
|
14
|
+
f"{alert_result.message}"
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
if self.channel:
|
|
18
|
+
message["channel"] = self.channel
|
|
19
|
+
response = requests.post(self.webhook_url, json=message, timeout=10)
|
|
20
|
+
response.raise_for_status()
|