keepup-audit 0.4.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.
- keepup_audit-0.4.0/PKG-INFO +18 -0
- keepup_audit-0.4.0/README.md +10 -0
- keepup_audit-0.4.0/keepup_audit/__init__.py +12 -0
- keepup_audit-0.4.0/keepup_audit/events.py +496 -0
- keepup_audit-0.4.0/keepup_audit/tables.py +57 -0
- keepup_audit-0.4.0/keepup_audit.egg-info/PKG-INFO +18 -0
- keepup_audit-0.4.0/keepup_audit.egg-info/SOURCES.txt +11 -0
- keepup_audit-0.4.0/keepup_audit.egg-info/dependency_links.txt +1 -0
- keepup_audit-0.4.0/keepup_audit.egg-info/entry_points.txt +2 -0
- keepup_audit-0.4.0/keepup_audit.egg-info/requires.txt +1 -0
- keepup_audit-0.4.0/keepup_audit.egg-info/top_level.txt +1 -0
- keepup_audit-0.4.0/pyproject.toml +19 -0
- keepup_audit-0.4.0/setup.cfg +4 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keepup-audit
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Incoming calls, the application's events, the administrator's trail and their retention.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: keepup-db>=0.4.0
|
|
8
|
+
|
|
9
|
+
# keepup-audit
|
|
10
|
+
|
|
11
|
+
Incoming calls, the application's events, the administrator's trail and their retention.
|
|
12
|
+
|
|
13
|
+
One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
|
|
14
|
+
panels: the base is installed as `keepup-admin` and imported as `keepup`, and
|
|
15
|
+
this distribution carries one part of it. It is installed on its own
|
|
16
|
+
(`pip install keepup-audit`) or together with the base (`pip install "keepup-admin[panel]"`),
|
|
17
|
+
and it declares itself to the kernel through its
|
|
18
|
+
`[project.entry-points."keepup.plugins"]` entry point.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# keepup-audit
|
|
2
|
+
|
|
3
|
+
Incoming calls, the application's events, the administrator's trail and their retention.
|
|
4
|
+
|
|
5
|
+
One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
|
|
6
|
+
panels: the base is installed as `keepup-admin` and imported as `keepup`, and
|
|
7
|
+
this distribution carries one part of it. It is installed on its own
|
|
8
|
+
(`pip install keepup-audit`) or together with the base (`pip install "keepup-admin[panel]"`),
|
|
9
|
+
and it declares itself to the kernel through its
|
|
10
|
+
`[project.entry-points."keepup.plugins"]` entry point.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Incoming calls, the application's events, the administrator's trail and their retention.
|
|
2
|
+
|
|
3
|
+
This distribution provides `audit, events` and declares one plugin,
|
|
4
|
+
`audit`, in the `keepup.plugins` group. It depends on `keepup-admin`
|
|
5
|
+
for the constructor -- the descriptor, the catalogue, the service registry and
|
|
6
|
+
the lifecycle -- and on nothing of another capability.
|
|
7
|
+
|
|
8
|
+
The code moves here in keepup-124: until then the plugin lives in the framework's
|
|
9
|
+
own `builtin/audit.py` and this package is the home it is moving to.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.4.0"
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""The application's event log.
|
|
2
|
+
|
|
3
|
+
Not the request audit (`keepup/audit.py`) and not the application log
|
|
4
|
+
(`keepup/logging_setup.py`): this records things that happened in the product's
|
|
5
|
+
own terms -- somebody signed in, an order was accepted, a job failed -- so that
|
|
6
|
+
they can be read back by type and by actor long after the log files rotated.
|
|
7
|
+
|
|
8
|
+
Which types exist is the application's business and arrives through
|
|
9
|
+
KeepupSettings: the framework holds the table, the API and the retention, and
|
|
10
|
+
knows none of the names. See `doc/event_manager.md`.
|
|
11
|
+
|
|
12
|
+
This module is the journal -- the table, writing, reading, retention. Its HTTP
|
|
13
|
+
routes are `keepup/events_api.py` (keepup-23): the journal is written from
|
|
14
|
+
everywhere in an application, the routes are one reader of it among others,
|
|
15
|
+
and a module that only emits events has no reason to load FastAPI.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import logging
|
|
20
|
+
import json
|
|
21
|
+
from datetime import datetime, timedelta
|
|
22
|
+
from typing import Optional, List, Dict, Any
|
|
23
|
+
from sqlalchemy import Index
|
|
24
|
+
|
|
25
|
+
from keepup import retention
|
|
26
|
+
from keepup_db import tables
|
|
27
|
+
from keepup_db import DatabaseManagerV2
|
|
28
|
+
from keepup.instance import get_instance_id, get_instance_name
|
|
29
|
+
|
|
30
|
+
#: What an application may import from this module. Everything else is
|
|
31
|
+
#: internal and may change without notice -- see doc/keepup.md.
|
|
32
|
+
__all__ = [
|
|
33
|
+
"DEFAULT_EVENTS_RETENTION_DAYS",
|
|
34
|
+
"emit_event",
|
|
35
|
+
"events_retention_background",
|
|
36
|
+
"init_event_manager",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# Declared by the audit capability, which keeps the events (keepup-124).
|
|
40
|
+
from keepup_audit.tables import APP_EVENTS # noqa: E402
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
# Newest first, as the listing pages; the type-and-date pair was only ever
|
|
45
|
+
# created on PostgreSQL.
|
|
46
|
+
Index("idx_app_events_created_at", APP_EVENTS.c.created_at.desc())
|
|
47
|
+
Index("idx_app_events_type_date", APP_EVENTS.c.event_type,
|
|
48
|
+
APP_EVENTS.c.created_at.desc()).ddl_if(dialect="postgresql")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class EventManager:
|
|
52
|
+
"""Application event manager."""
|
|
53
|
+
|
|
54
|
+
def __init__(self):
|
|
55
|
+
self.instance_id = get_instance_id()
|
|
56
|
+
self.instance_name = get_instance_name()
|
|
57
|
+
self._initialized = False
|
|
58
|
+
|
|
59
|
+
def init_table(self):
|
|
60
|
+
"""Create the app_events table."""
|
|
61
|
+
try:
|
|
62
|
+
tables.ensure_tables(APP_EVENTS)
|
|
63
|
+
self._initialized = True
|
|
64
|
+
logger.info(f"App events table initialized successfully (instance: {self.instance_name})")
|
|
65
|
+
|
|
66
|
+
except Exception as e:
|
|
67
|
+
logger.error(f"Error initializing app events table: {str(e)}")
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
async def create_event(
|
|
71
|
+
self,
|
|
72
|
+
event_type: str,
|
|
73
|
+
event_text: str,
|
|
74
|
+
event_data: Optional[Dict[str, Any]] = None,
|
|
75
|
+
instance_id: Optional[str] = None,
|
|
76
|
+
instance_name: Optional[str] = None
|
|
77
|
+
) -> int:
|
|
78
|
+
"""Create an event.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
event_type: event type
|
|
82
|
+
event_text: event text
|
|
83
|
+
event_data: optional extra payload
|
|
84
|
+
instance_id: instance id, defaults to the current instance
|
|
85
|
+
instance_name: instance name, defaults to the current instance
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
int: id of the created event.
|
|
89
|
+
"""
|
|
90
|
+
if not self._initialized:
|
|
91
|
+
await asyncio.to_thread(self.init_table)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
event_instance_id = instance_id or self.instance_id
|
|
95
|
+
event_instance_name = instance_name or self.instance_name
|
|
96
|
+
|
|
97
|
+
event_data_json = json.dumps(event_data, ensure_ascii=False) if event_data else None
|
|
98
|
+
|
|
99
|
+
row = await DatabaseManagerV2.execute_commit_returning_async('''
|
|
100
|
+
INSERT INTO app_events (event_type, event_text, event_data, instance_id, instance_name)
|
|
101
|
+
VALUES (:event_type, :event_text, :event_data, :instance_id, :instance_name)
|
|
102
|
+
''', {"event_type": event_type, "event_text": event_text,
|
|
103
|
+
"event_data": event_data_json, "instance_id": event_instance_id,
|
|
104
|
+
"instance_name": event_instance_name}, "id")
|
|
105
|
+
logger.debug(f"Event created: {event_type} - {event_text[:50]} (instance: {event_instance_name})")
|
|
106
|
+
return row["id"] if row else None
|
|
107
|
+
|
|
108
|
+
except Exception as e:
|
|
109
|
+
logger.error(f"Error creating event: {str(e)}")
|
|
110
|
+
raise
|
|
111
|
+
|
|
112
|
+
async def get_events(
|
|
113
|
+
self,
|
|
114
|
+
event_type: Optional[str] = None,
|
|
115
|
+
instance_id: Optional[str] = None,
|
|
116
|
+
instance_name: Optional[str] = None,
|
|
117
|
+
start_date: Optional[datetime] = None,
|
|
118
|
+
end_date: Optional[datetime] = None,
|
|
119
|
+
page: int = 1,
|
|
120
|
+
page_size: int = 50
|
|
121
|
+
) -> Dict[str, Any]:
|
|
122
|
+
"""Return events, filtered and paginated.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
event_type: filter by event type
|
|
126
|
+
instance_id: filter by instance id
|
|
127
|
+
instance_name: filter by instance name
|
|
128
|
+
start_date: lower date bound
|
|
129
|
+
end_date: upper date bound
|
|
130
|
+
page: page number, starting at 1
|
|
131
|
+
page_size: page size
|
|
132
|
+
|
|
133
|
+
Returns:
|
|
134
|
+
Dict with the events and their metadata.
|
|
135
|
+
"""
|
|
136
|
+
if not self._initialized:
|
|
137
|
+
await asyncio.to_thread(self.init_table)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
conditions, params = [], {}
|
|
141
|
+
for column, operator, name, value in (
|
|
142
|
+
("event_type", "=", "event_type", event_type),
|
|
143
|
+
("instance_id", "=", "instance_id", instance_id),
|
|
144
|
+
("instance_name", "=", "instance_name", instance_name),
|
|
145
|
+
("created_at", ">=", "start_date", start_date),
|
|
146
|
+
("created_at", "<=", "end_date", end_date)):
|
|
147
|
+
if value:
|
|
148
|
+
conditions.append(f"{column} {operator} :{name}")
|
|
149
|
+
params[name] = value
|
|
150
|
+
|
|
151
|
+
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
|
152
|
+
|
|
153
|
+
total_result = await DatabaseManagerV2.execute_one_async(
|
|
154
|
+
f"SELECT COUNT(*) as total FROM app_events {where_clause}", params)
|
|
155
|
+
total = total_result['total'] if total_result else 0
|
|
156
|
+
|
|
157
|
+
offset = (page - 1) * page_size
|
|
158
|
+
rows = await DatabaseManagerV2.execute_async(f'''
|
|
159
|
+
SELECT id, event_type, event_text, event_data, instance_id, instance_name, created_at
|
|
160
|
+
FROM app_events
|
|
161
|
+
{where_clause}
|
|
162
|
+
ORDER BY created_at DESC
|
|
163
|
+
LIMIT :limit OFFSET :offset
|
|
164
|
+
''', {**params, "limit": page_size, "offset": offset})
|
|
165
|
+
|
|
166
|
+
events = []
|
|
167
|
+
for row in rows:
|
|
168
|
+
event = {
|
|
169
|
+
'id': row['id'],
|
|
170
|
+
'event_type': row['event_type'],
|
|
171
|
+
'event_text': row['event_text'],
|
|
172
|
+
'instance_id': row['instance_id'],
|
|
173
|
+
'instance_name': row['instance_name'] if len(row) > 5 else None,
|
|
174
|
+
'created_at': row['created_at'].isoformat() if len(row) > 6 and hasattr(row['created_at'], 'isoformat') else str(
|
|
175
|
+
row['created_at']) if len(row) > 6 else None
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
# Deprecated: `event_data` has always carried the *text*, parsed
|
|
179
|
+
# as JSON when it happened to be JSON, and the event's own data
|
|
180
|
+
# was not in the answer at all (keepup-57). Kept as it was for
|
|
181
|
+
# whoever reads it; goes in the next major release.
|
|
182
|
+
if len(row) > 3 and row['event_text']:
|
|
183
|
+
try:
|
|
184
|
+
if isinstance(row['event_text'], str):
|
|
185
|
+
event['event_data'] = json.loads(row['event_text'])
|
|
186
|
+
else:
|
|
187
|
+
event['event_data'] = row['event_text']
|
|
188
|
+
except (TypeError, ValueError):
|
|
189
|
+
event['event_data'] = row['event_text']
|
|
190
|
+
|
|
191
|
+
event['data'] = _stored_data(row['event_data'])
|
|
192
|
+
|
|
193
|
+
events.append(event)
|
|
194
|
+
|
|
195
|
+
total_pages = (total + page_size - 1) // page_size
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
'events': events,
|
|
199
|
+
'total': total,
|
|
200
|
+
'page': page,
|
|
201
|
+
'page_size': page_size,
|
|
202
|
+
'total_pages': total_pages
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
except Exception as e:
|
|
206
|
+
logger.error(f"Error getting events: {str(e)}")
|
|
207
|
+
raise
|
|
208
|
+
|
|
209
|
+
async def get_event_types(self) -> List[str]:
|
|
210
|
+
"""Return every distinct event type.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
List[str]: the event types.
|
|
214
|
+
"""
|
|
215
|
+
if not self._initialized:
|
|
216
|
+
await asyncio.to_thread(self.init_table)
|
|
217
|
+
|
|
218
|
+
try:
|
|
219
|
+
rows = await DatabaseManagerV2.execute_async(
|
|
220
|
+
"SELECT DISTINCT event_type FROM app_events ORDER BY event_type")
|
|
221
|
+
return [row['event_type'] for row in rows]
|
|
222
|
+
|
|
223
|
+
except Exception as e:
|
|
224
|
+
logger.error(f"Error getting event types: {str(e)}")
|
|
225
|
+
return []
|
|
226
|
+
|
|
227
|
+
async def get_instances(self) -> List[Dict[str, str]]:
|
|
228
|
+
"""Return every instance that has produced events.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
List[Dict]: instances with their id and name.
|
|
232
|
+
"""
|
|
233
|
+
if not self._initialized:
|
|
234
|
+
await asyncio.to_thread(self.init_table)
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
rows = await DatabaseManagerV2.execute_async('''
|
|
238
|
+
SELECT DISTINCT instance_id, instance_name
|
|
239
|
+
FROM app_events
|
|
240
|
+
WHERE instance_name IS NOT NULL
|
|
241
|
+
ORDER BY instance_name
|
|
242
|
+
''')
|
|
243
|
+
return [{'instance_id': row['instance_id'], 'instance_name': row['instance_name']}
|
|
244
|
+
for row in rows]
|
|
245
|
+
|
|
246
|
+
except Exception as e:
|
|
247
|
+
logger.error(f"Error getting instances: {str(e)}")
|
|
248
|
+
return []
|
|
249
|
+
|
|
250
|
+
async def delete_old_events(self, days: int = 30) -> int:
|
|
251
|
+
"""Delete events older than the given number of days.
|
|
252
|
+
|
|
253
|
+
Args:
|
|
254
|
+
days: age threshold in days, 30 by default
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
int: number of deleted events.
|
|
258
|
+
"""
|
|
259
|
+
if not self._initialized:
|
|
260
|
+
await asyncio.to_thread(self.init_table)
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
# The boundary is computed here rather than written into the
|
|
264
|
+
# statement. It used to be `INTERVAL '%s days'` -- the placeholder
|
|
265
|
+
# inside a string literal, where the driver's quoting does not
|
|
266
|
+
# reach: a string argument would have left the literal. Nothing
|
|
267
|
+
# exploited it only because the one caller passes an int from a
|
|
268
|
+
# bounded query parameter, and this is a public method (keepup-15).
|
|
269
|
+
cutoff = datetime.utcnow() - timedelta(days=int(days))
|
|
270
|
+
deleted_count = await DatabaseManagerV2.execute_commit_async(
|
|
271
|
+
"DELETE FROM app_events WHERE created_at < :cutoff", {"cutoff": cutoff})
|
|
272
|
+
if deleted_count > 0:
|
|
273
|
+
logger.info(f"Deleted {deleted_count} old events (older than {days} days)")
|
|
274
|
+
return deleted_count
|
|
275
|
+
|
|
276
|
+
except Exception as e:
|
|
277
|
+
logger.error(f"Error deleting old events: {str(e)}")
|
|
278
|
+
return 0
|
|
279
|
+
|
|
280
|
+
async def get_events_stats(self) -> Dict[str, Any]:
|
|
281
|
+
"""Return event statistics grouped by instance.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
Dict: the statistics.
|
|
285
|
+
"""
|
|
286
|
+
if not self._initialized:
|
|
287
|
+
await asyncio.to_thread(self.init_table)
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
# One statement for both dialects; rows are read by column name,
|
|
291
|
+
# which is what a PostgreSQL row is (it has no positions).
|
|
292
|
+
type_rows = await DatabaseManagerV2.execute_async('''
|
|
293
|
+
SELECT event_type, COUNT(*) as count,
|
|
294
|
+
MIN(created_at) as first_event, MAX(created_at) as last_event
|
|
295
|
+
FROM app_events
|
|
296
|
+
GROUP BY event_type
|
|
297
|
+
ORDER BY count DESC
|
|
298
|
+
''')
|
|
299
|
+
instance_rows = await DatabaseManagerV2.execute_async('''
|
|
300
|
+
SELECT instance_id, instance_name, COUNT(*) as event_count,
|
|
301
|
+
MIN(created_at) as first_event, MAX(created_at) as last_event
|
|
302
|
+
FROM app_events
|
|
303
|
+
GROUP BY instance_id, instance_name
|
|
304
|
+
ORDER BY event_count DESC
|
|
305
|
+
''')
|
|
306
|
+
|
|
307
|
+
def moment(value):
|
|
308
|
+
return value.isoformat() if hasattr(value, 'isoformat') else str(value)
|
|
309
|
+
|
|
310
|
+
type_stats = [{
|
|
311
|
+
'event_type': row['event_type'],
|
|
312
|
+
'count': row['count'],
|
|
313
|
+
'first_event': moment(row['first_event']),
|
|
314
|
+
'last_event': moment(row['last_event']),
|
|
315
|
+
} for row in type_rows]
|
|
316
|
+
instance_stats = [{
|
|
317
|
+
'instance_id': row['instance_id'],
|
|
318
|
+
'instance_name': row['instance_name'],
|
|
319
|
+
'event_count': row['event_count'],
|
|
320
|
+
'first_event': moment(row['first_event']),
|
|
321
|
+
'last_event': moment(row['last_event']),
|
|
322
|
+
} for row in instance_rows]
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
"total_events": sum(stat['count'] for stat in type_stats),
|
|
326
|
+
"unique_event_types": len(type_stats),
|
|
327
|
+
"unique_instances": len(instance_stats),
|
|
328
|
+
"event_types": type_stats,
|
|
329
|
+
"instances": instance_stats,
|
|
330
|
+
"current_instance": {
|
|
331
|
+
"id": self.instance_id,
|
|
332
|
+
"name": self.instance_name
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
except Exception as e:
|
|
337
|
+
logger.error(f"Error getting events stats: {str(e)}")
|
|
338
|
+
return {
|
|
339
|
+
"total_events": 0,
|
|
340
|
+
"unique_event_types": 0,
|
|
341
|
+
"unique_instances": 0,
|
|
342
|
+
"event_types": [],
|
|
343
|
+
"instances": [],
|
|
344
|
+
"current_instance": {
|
|
345
|
+
"id": self.instance_id,
|
|
346
|
+
"name": self.instance_name
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
event_manager = EventManager()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# --- how long an event lives --------------------------------------------------
|
|
355
|
+
|
|
356
|
+
#: How many days an event is kept (``EVENTS_RETENTION_DAYS``). The log was never
|
|
357
|
+
#: swept on its own -- only a manual route deleted old events -- so it grew for as
|
|
358
|
+
#: long as the deployment ran. Longer than the request audit: an event is the
|
|
359
|
+
#: product's own record of what happened (a sign-in, a blocked account), read
|
|
360
|
+
#: back by an administrator weeks later.
|
|
361
|
+
DEFAULT_EVENTS_RETENTION_DAYS = 90
|
|
362
|
+
#: How often the sweep runs, and how much one pass may delete.
|
|
363
|
+
EVENTS_SWEEP_INTERVAL_SECONDS = 3600
|
|
364
|
+
EVENTS_DELETE_CHUNK_ROWS = 5000
|
|
365
|
+
EVENTS_MAX_CHUNKS_PER_PASS = 20
|
|
366
|
+
#: How long to wait after a failed pass.
|
|
367
|
+
EVENTS_ERROR_BACKOFF_SECONDS = 60
|
|
368
|
+
|
|
369
|
+
EVENTS_RETENTION_LOCK = "app_events_retention"
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _stored_data(value):
|
|
373
|
+
"""The event's data as it was written: a mapping, or None when there is none.
|
|
374
|
+
|
|
375
|
+
Stored as JSON text; a value that does not parse is handed back as it is
|
|
376
|
+
rather than dropped, so nothing written is lost from the answer.
|
|
377
|
+
"""
|
|
378
|
+
if value is None or value == "":
|
|
379
|
+
return None
|
|
380
|
+
if isinstance(value, str):
|
|
381
|
+
try:
|
|
382
|
+
return json.loads(value)
|
|
383
|
+
except ValueError:
|
|
384
|
+
return value
|
|
385
|
+
return value
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def events_retention_days() -> int:
|
|
389
|
+
"""How many days events are kept (``EVENTS_RETENTION_DAYS``, 90 by default)."""
|
|
390
|
+
return retention.retention_days("EVENTS_RETENTION_DAYS", DEFAULT_EVENTS_RETENTION_DAYS)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def purge_old_events(days: Optional[int] = None, now: Optional[datetime] = None) -> int:
|
|
394
|
+
"""Delete events older than the retention period, in chunks; how many went."""
|
|
395
|
+
return retention.purge_older_than(
|
|
396
|
+
"app_events", "created_at",
|
|
397
|
+
days if days is not None else events_retention_days(),
|
|
398
|
+
EVENTS_DELETE_CHUNK_ROWS, EVENTS_MAX_CHUNKS_PER_PASS, now=now)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
async def events_retention_background():
|
|
402
|
+
"""Sweep the event log once per interval, under a distributed lock.
|
|
403
|
+
|
|
404
|
+
Locked because the work is the deployment's, not the replica's. A failed pass
|
|
405
|
+
costs nothing: writing events runs on its own path.
|
|
406
|
+
"""
|
|
407
|
+
from keepup.locks import distributed_lock
|
|
408
|
+
|
|
409
|
+
while True:
|
|
410
|
+
try:
|
|
411
|
+
async with distributed_lock(EVENTS_RETENTION_LOCK, timeout=5,
|
|
412
|
+
max_lock_time=EVENTS_SWEEP_INTERVAL_SECONDS):
|
|
413
|
+
removed = await asyncio.to_thread(purge_old_events)
|
|
414
|
+
if removed:
|
|
415
|
+
logger.info("Events expired: %s", removed)
|
|
416
|
+
await asyncio.sleep(EVENTS_SWEEP_INTERVAL_SECONDS)
|
|
417
|
+
except asyncio.CancelledError:
|
|
418
|
+
raise
|
|
419
|
+
except Exception as error:
|
|
420
|
+
logger.error("Error in the event log retention sweep: %s", error)
|
|
421
|
+
await asyncio.sleep(EVENTS_ERROR_BACKOFF_SECONDS)
|
|
422
|
+
|
|
423
|
+
async def emit_event(
|
|
424
|
+
event_type: str,
|
|
425
|
+
event_text: str,
|
|
426
|
+
event_data: Optional[Dict[str, Any]] = None,
|
|
427
|
+
instance_id: Optional[str] = None,
|
|
428
|
+
instance_name: Optional[str] = None
|
|
429
|
+
) -> int:
|
|
430
|
+
"""Emit an event from anywhere in the application.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
event_type: event type
|
|
434
|
+
event_text: event text
|
|
435
|
+
event_data: optional extra payload
|
|
436
|
+
instance_id: instance id
|
|
437
|
+
instance_name: instance name
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
int: id of the created event.
|
|
441
|
+
"""
|
|
442
|
+
return await event_manager.create_event(
|
|
443
|
+
event_type=event_type,
|
|
444
|
+
event_text=event_text,
|
|
445
|
+
event_data=event_data,
|
|
446
|
+
instance_id=instance_id,
|
|
447
|
+
instance_name=instance_name
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
async def get_events_paginated(
|
|
452
|
+
event_type: Optional[str] = None,
|
|
453
|
+
instance_id: Optional[str] = None,
|
|
454
|
+
instance_name: Optional[str] = None,
|
|
455
|
+
start_date: Optional[datetime] = None,
|
|
456
|
+
end_date: Optional[datetime] = None,
|
|
457
|
+
page: int = 1,
|
|
458
|
+
page_size: int = 50
|
|
459
|
+
) -> Dict[str, Any]:
|
|
460
|
+
"""Return events with pagination."""
|
|
461
|
+
return await event_manager.get_events(
|
|
462
|
+
event_type=event_type,
|
|
463
|
+
instance_id=instance_id,
|
|
464
|
+
instance_name=instance_name,
|
|
465
|
+
start_date=start_date,
|
|
466
|
+
end_date=end_date,
|
|
467
|
+
page=page,
|
|
468
|
+
page_size=page_size
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def init_event_manager():
|
|
473
|
+
"""Initialise the event manager at application start."""
|
|
474
|
+
try:
|
|
475
|
+
event_manager.init_table()
|
|
476
|
+
logger.info(f"Event manager initialized successfully (instance: {event_manager.instance_name})")
|
|
477
|
+
return event_manager
|
|
478
|
+
except Exception as e:
|
|
479
|
+
logger.error(f"Failed to initialize event manager: {str(e)}")
|
|
480
|
+
return None
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
#: Names that moved to keepup/events_api.py (keepup-23). Still answered here for
|
|
484
|
+
#: code written against 0.1, with a warning naming the new place; looked up on
|
|
485
|
+
#: access, so the journal does not import its API.
|
|
486
|
+
_MOVED_TO_API = {"register_event_api_routes", "AppEvent", "EventCreate", "EventListResponse"}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def __getattr__(name):
|
|
490
|
+
if name in _MOVED_TO_API:
|
|
491
|
+
import warnings
|
|
492
|
+
from keepup import events_api
|
|
493
|
+
warnings.warn(f"keepup.events.{name} moved to keepup.events_api.{name}",
|
|
494
|
+
DeprecationWarning, stacklevel=2)
|
|
495
|
+
return getattr(events_api, name)
|
|
496
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""The tables of the audit capability, declared where it lives.
|
|
2
|
+
|
|
3
|
+
Task keepup-124. `keepup.audit` and `keepup.events` re-export them for one
|
|
4
|
+
release, so the path that predates the catalogue still creates them and every
|
|
5
|
+
reader keeps working; the declarations belong to the capability that keeps the
|
|
6
|
+
rows -- the calls that came in and the events the application decided about.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import (CheckConstraint, Column, DateTime, Index, Integer,
|
|
10
|
+
String, Text, UniqueConstraint)
|
|
11
|
+
from sqlalchemy.dialects import postgresql
|
|
12
|
+
|
|
13
|
+
from keepup_db import tables
|
|
14
|
+
|
|
15
|
+
__all__ = ["APP_EVENTS", "INCOMING_REQUESTS"]
|
|
16
|
+
|
|
17
|
+
INCOMING_REQUESTS = tables.table(
|
|
18
|
+
"incoming_requests",
|
|
19
|
+
tables.auto_id(),
|
|
20
|
+
Column("instance_id", Text, nullable=False),
|
|
21
|
+
Column("method", Text, nullable=False),
|
|
22
|
+
Column("endpoint", Text, nullable=False),
|
|
23
|
+
Column("host", Text, nullable=False),
|
|
24
|
+
Column("request_data", postgresql.JSONB().with_variant(Text(), "sqlite")),
|
|
25
|
+
Column("request_start_at", DateTime, nullable=False),
|
|
26
|
+
Column("request_end_at", DateTime),
|
|
27
|
+
Column("duration_ms", Integer),
|
|
28
|
+
Column("http_status", Integer),
|
|
29
|
+
Column("response_data", postgresql.JSONB().with_variant(Text(), "sqlite")),
|
|
30
|
+
Column("error_message", Text),
|
|
31
|
+
Column("created_at", DateTime, server_default=tables.NOW),
|
|
32
|
+
UniqueConstraint("instance_id", "method", "endpoint", "request_start_at",
|
|
33
|
+
name="incoming_requests_instance_method_endpoint_unique").ddl_if(dialect="postgresql"),
|
|
34
|
+
CheckConstraint("duration_ms >= 0",
|
|
35
|
+
name="incoming_requests_check_duration").ddl_if(dialect="postgresql"),
|
|
36
|
+
CheckConstraint("(http_status IS NULL) OR (http_status >= 100 AND http_status <= 599)",
|
|
37
|
+
name="incoming_requests_check_http_status").ddl_if(dialect="postgresql"),
|
|
38
|
+
Index("incoming_requests_instance_method_endpoint_unique",
|
|
39
|
+
"instance_id", "method", "endpoint", "request_start_at", unique=True).ddl_if(dialect="sqlite"),
|
|
40
|
+
Index("idx_incoming_requests_instance_id", "instance_id"),
|
|
41
|
+
Index("idx_incoming_requests_endpoint", "endpoint"),
|
|
42
|
+
Index("idx_incoming_requests_created_at", "created_at"),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
APP_EVENTS = tables.table(
|
|
46
|
+
"app_events",
|
|
47
|
+
tables.big_auto_id(),
|
|
48
|
+
Column("event_type", String(100), nullable=False),
|
|
49
|
+
Column("event_text", Text, nullable=False),
|
|
50
|
+
Column("event_data", postgresql.JSONB().with_variant(Text(), "sqlite")),
|
|
51
|
+
Column("instance_id", String(100), nullable=False),
|
|
52
|
+
Column("instance_name", String(100)),
|
|
53
|
+
Column("created_at", DateTime, server_default=tables.NOW),
|
|
54
|
+
Index("idx_app_events_event_type", "event_type"),
|
|
55
|
+
Index("idx_app_events_instance_id", "instance_id"),
|
|
56
|
+
Index("idx_app_events_instance_name", "instance_name"),
|
|
57
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keepup-audit
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Incoming calls, the application's events, the administrator's trail and their retention.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: keepup-db>=0.4.0
|
|
8
|
+
|
|
9
|
+
# keepup-audit
|
|
10
|
+
|
|
11
|
+
Incoming calls, the application's events, the administrator's trail and their retention.
|
|
12
|
+
|
|
13
|
+
One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
|
|
14
|
+
panels: the base is installed as `keepup-admin` and imported as `keepup`, and
|
|
15
|
+
this distribution carries one part of it. It is installed on its own
|
|
16
|
+
(`pip install keepup-audit`) or together with the base (`pip install "keepup-admin[panel]"`),
|
|
17
|
+
and it declares itself to the kernel through its
|
|
18
|
+
`[project.entry-points."keepup.plugins"]` entry point.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
keepup_audit/__init__.py
|
|
4
|
+
keepup_audit/events.py
|
|
5
|
+
keepup_audit/tables.py
|
|
6
|
+
keepup_audit.egg-info/PKG-INFO
|
|
7
|
+
keepup_audit.egg-info/SOURCES.txt
|
|
8
|
+
keepup_audit.egg-info/dependency_links.txt
|
|
9
|
+
keepup_audit.egg-info/entry_points.txt
|
|
10
|
+
keepup_audit.egg-info/requires.txt
|
|
11
|
+
keepup_audit.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
keepup-db>=0.4.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
keepup_audit
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "keepup-audit"
|
|
7
|
+
version = "0.4.0"
|
|
8
|
+
description = "Incoming calls, the application's events, the administrator's trail and their retention."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"keepup-db>=0.4.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.entry-points."keepup.plugins"]
|
|
16
|
+
audit = "keepup_audit:AuditPlugin"
|
|
17
|
+
|
|
18
|
+
[tool.setuptools]
|
|
19
|
+
packages = ["keepup_audit"]
|