keepup-admin 0.1.0__tar.gz → 0.2.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.
Files changed (97) hide show
  1. {keepup_admin-0.1.0/keepup_admin.egg-info → keepup_admin-0.2.0}/PKG-INFO +47 -10
  2. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/README.md +37 -6
  3. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/audit.py +91 -141
  4. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/auth/config.py +1 -1
  5. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/auth/dependencies.py +46 -121
  6. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/auth/login_throttle.py +22 -21
  7. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/auth/oidc.py +13 -3
  8. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/auth/oidc_routes.py +2 -2
  9. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/auth/providers/base.py +46 -12
  10. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/auth/providers/local.py +30 -28
  11. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/auth/routes.py +58 -94
  12. keepup_admin-0.2.0/body_limit.py +152 -0
  13. keepup_admin-0.2.0/cache.py +169 -0
  14. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/cluster.py +21 -12
  15. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/db.py +84 -286
  16. keepup_admin-0.2.0/events.py +484 -0
  17. keepup_admin-0.2.0/events_api.py +209 -0
  18. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/factory.py +62 -8
  19. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/integrations.py +46 -82
  20. {keepup_admin-0.1.0 → keepup_admin-0.2.0/keepup_admin.egg-info}/PKG-INFO +47 -10
  21. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/keepup_admin.egg-info/SOURCES.txt +12 -0
  22. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/keepup_admin.egg-info/requires.txt +7 -2
  23. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/locks.py +82 -65
  24. keepup_admin-0.1.0/logging_setup.py → keepup_admin-0.2.0/log_shipping.py +24 -64
  25. keepup_admin-0.2.0/logging_setup.py +122 -0
  26. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/metrics.py +31 -262
  27. keepup_admin-0.2.0/metrics_api.py +281 -0
  28. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/metrics_retention.py +3 -2
  29. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/modules.py +118 -110
  30. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/notification_bus.py +33 -7
  31. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/plugins/admin.py +5 -3
  32. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/plugins/base.py +13 -1
  33. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/plugins/registry.py +20 -1
  34. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/plugins/routes.py +117 -6
  35. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/pyproject.toml +34 -4
  36. keepup_admin-0.2.0/retention.py +84 -0
  37. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/scheduler.py +38 -7
  38. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/schema.py +21 -21
  39. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/settings.py +42 -2
  40. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/css/main_new.css +17 -0
  41. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/js/main_new.js +49 -3
  42. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/themes.py +28 -12
  43. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/web.py +6 -7
  44. keepup_admin-0.1.0/events.py +0 -764
  45. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/LICENSE +0 -0
  46. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/NOTICE +0 -0
  47. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/THIRD-PARTY.md +0 -0
  48. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/__init__.py +0 -0
  49. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/api_versions.py +0 -0
  50. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/auth/__init__.py +0 -0
  51. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/auth/dto/__init__.py +0 -0
  52. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/auth/dto/token.py +0 -0
  53. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/auth/factory.py +0 -0
  54. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/auth/oidc_policy.py +0 -0
  55. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/auth/panel_session.py +0 -0
  56. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/auth/permissions.py +0 -0
  57. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/auth/providers/__init__.py +0 -0
  58. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/auth/seed_accounts.py +0 -0
  59. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/auth/session_lifetime.py +0 -0
  60. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/auth/signing_key.py +0 -0
  61. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/auth/websocket.py +0 -0
  62. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/instance.py +0 -0
  63. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/keepup_admin.egg-info/dependency_links.txt +0 -0
  64. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/keepup_admin.egg-info/top_level.txt +0 -0
  65. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/plugins/__init__.py +0 -0
  66. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/plugins/enablement.py +0 -0
  67. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/plugins/route_mask.py +0 -0
  68. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/roles.py +0 -0
  69. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/sections.json +0 -0
  70. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/setup.cfg +0 -0
  71. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/css/aos.css +0 -0
  72. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/css/main_nebula.css +0 -0
  73. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/css/tailwind.css +0 -0
  74. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/index_nebula.html +0 -0
  75. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/index_new.html +0 -0
  76. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/js/aos.js +0 -0
  77. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/js/feather-icons.js +0 -0
  78. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/js/tailwind.js +0 -0
  79. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/modules/css/background_tasks.css +0 -0
  80. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/modules/css/cluster.css +0 -0
  81. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/modules/css/event_manager.css +0 -0
  82. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/modules/css/integration_logs.css +0 -0
  83. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/modules/css/metrics.css +0 -0
  84. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/modules/css/modules.css +0 -0
  85. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/modules/css/themes.css +0 -0
  86. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/modules/css/users.css +0 -0
  87. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/modules/js/background_tasks.js +0 -0
  88. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/modules/js/chart.js +0 -0
  89. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +0 -0
  90. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/modules/js/cluster.js +0 -0
  91. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/modules/js/event_manager.js +0 -0
  92. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/modules/js/integration_logs.js +0 -0
  93. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/modules/js/metrics.js +0 -0
  94. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/modules/js/modules.js +0 -0
  95. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/modules/js/themes.js +0 -0
  96. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/static/modules/js/users.js +0 -0
  97. {keepup_admin-0.1.0 → keepup_admin-0.2.0}/tables.py +0 -0
@@ -1,11 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keepup-admin
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: An admin framework over FastAPI: plugins, panel, users, replicas
5
5
  Author-email: Fedor Sergeev <fksergeev@gmail.com>
6
6
  License-Expression: Apache-2.0
7
- Project-URL: Homepage, https://github.com/FedorSergeev/keepup-admin
7
+ Project-URL: Homepage, https://fedorsergeev.github.io/keepup-admin/
8
8
  Project-URL: Repository, https://github.com/FedorSergeev/keepup-admin
9
+ Project-URL: Changelog, https://github.com/FedorSergeev/keepup-admin/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/FedorSergeev/keepup-admin/issues
9
11
  Keywords: fastapi,admin,plugins,framework
10
12
  Classifier: Framework :: FastAPI
11
13
  Classifier: Programming Language :: Python :: 3.11
@@ -17,25 +19,47 @@ License-File: LICENSE
17
19
  License-File: NOTICE
18
20
  License-File: THIRD-PARTY.md
19
21
  Requires-Dist: fastapi<1,>=0.128
20
- Requires-Dist: starlette<1,>=0.52
22
+ Requires-Dist: starlette<2,>=1.3.1
21
23
  Requires-Dist: pydantic<3,>=2.11
22
24
  Requires-Dist: sqlalchemy<3,>=2.0
23
25
  Requires-Dist: psycopg2-binary>=2.9
24
26
  Requires-Dist: apscheduler<4,>=3.10
25
27
  Requires-Dist: bcrypt<5,>=4.0
26
- Requires-Dist: python-jose<4,>=3.5
28
+ Requires-Dist: pyjwt[crypto]<3,>=2.10
27
29
  Requires-Dist: python-multipart>=0.0.20
28
30
  Requires-Dist: PyYAML<7,>=6.0
29
31
  Requires-Dist: psutil>=7.0
30
32
  Requires-Dist: prometheus-client>=0.24
31
33
  Requires-Dist: requests>=2.31
32
34
  Requires-Dist: httpx>=0.28
35
+ Provides-Extra: test
36
+ Requires-Dist: pytest>=8; extra == "test"
37
+ Requires-Dist: pytest-asyncio>=0.24; extra == "test"
38
+ Requires-Dist: pytest-cov>=5; extra == "test"
33
39
  Provides-Extra: postgres
34
40
  Requires-Dist: asyncpg>=0.31; extra == "postgres"
35
41
  Dynamic: license-file
36
42
 
37
43
  # keepup
38
44
 
45
+ [![tests](https://github.com/FedorSergeev/keepup-admin/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/FedorSergeev/keepup-admin/actions/workflows/tests.yml)
46
+ [![security](https://github.com/FedorSergeev/keepup-admin/actions/workflows/security.yml/badge.svg?branch=main)](https://github.com/FedorSergeev/keepup-admin/actions/workflows/security.yml)
47
+ [![PyPI](https://img.shields.io/pypi/v/keepup-admin)](https://pypi.org/project/keepup-admin/)
48
+ [![licence](https://img.shields.io/pypi/l/keepup-admin)](LICENSE)
49
+
50
+ [Project page](https://fedorsergeev.github.io/keepup-admin/) ·
51
+ [Package](https://pypi.org/project/keepup-admin/) ·
52
+ [Changes](CHANGELOG.md)
53
+
54
+ Four badges, not six checks. These answer what somebody deciding whether to
55
+ install asks: does it run on my Python, is there a known vulnerability in what
56
+ it pulls in, what will I get, and may I use it. `quality`, `build` and `codeql`
57
+ are housekeeping and live on the Actions page -- six badges in a row stop being
58
+ read at all.
59
+
60
+ A badge shows; it does not require. What keeps a red check from being merged is
61
+ a branch rule, not a picture -- see `doc/keepup.md`.
62
+
39
63
  An admin framework over FastAPI. You give it settings; it gives you an
40
64
  application with a panel, a plugin runtime, users, a database layer, an audit,
41
65
  metrics and coordination between replicas already wired.
@@ -63,7 +87,7 @@ so the import name is free.
63
87
  | **Panel** | the shell, visual themes, and the catalogue of sections with role grants |
64
88
  | **Users** | local accounts, roles, panel sessions, sign-in through an external provider |
65
89
  | **Database** | a SQLAlchemy layer with a pool, table declarations for two dialects |
66
- | **Replicas** | distributed locks, leader election, a message bus, a cluster registry |
90
+ | **Replicas** | distributed locks, a message bus, a cluster registry, performance settings in one place |
67
91
  | **Observability** | audit of incoming requests, an event log, metrics, log shipping |
68
92
 
69
93
  ## What it does not decide for you
@@ -98,13 +122,26 @@ between replicas needs.
98
122
  ## Tests
99
123
 
100
124
  ```bash
101
- ci/tests/keepup.sh # the framework's own suite, needs no application
125
+ pip install -e ".[test]"
126
+ pytest tests/
102
127
  ```
103
128
 
104
- The suite runs against SQLite with no network and holds a coverage floor. The
105
- plugin runtime is the least covered part of the framework, and deliberately so
106
- for now: the test that exercises it hardest stands up a real application's
107
- plugins and therefore lives with that application. See `doc/keepup.md`.
129
+ **Install first, then test.** Pointing pytest at the checkout does not work on
130
+ its own: `import keepup` has to resolve, and the directory a clone lands in is
131
+ called `keepup-admin`, so nothing is named `keepup` until the package is
132
+ installed. The `test` extra carries what the suite needs beyond the package —
133
+ notably `cryptography`, which the framework itself never imports and the OIDC
134
+ tests sign tokens with.
135
+
136
+ The suite runs against SQLite with no network, needs no application beside the
137
+ package, and holds a coverage floor declared in `pyproject.toml`. Checks that
138
+ genuinely need a consumer of the framework find one by trait and skip with a
139
+ reason when there is none, so a skip here always says what is missing.
140
+
141
+ Every one of these runs on push and on a pull request; see `.github/workflows/`.
142
+ `security.yml` also runs weekly, because that check goes red without anybody
143
+ touching the repository — an advisory gets published against a version that was
144
+ fine yesterday.
108
145
 
109
146
  ## Licence
110
147
 
@@ -1,5 +1,23 @@
1
1
  # keepup
2
2
 
3
+ [![tests](https://github.com/FedorSergeev/keepup-admin/actions/workflows/tests.yml/badge.svg?branch=main)](https://github.com/FedorSergeev/keepup-admin/actions/workflows/tests.yml)
4
+ [![security](https://github.com/FedorSergeev/keepup-admin/actions/workflows/security.yml/badge.svg?branch=main)](https://github.com/FedorSergeev/keepup-admin/actions/workflows/security.yml)
5
+ [![PyPI](https://img.shields.io/pypi/v/keepup-admin)](https://pypi.org/project/keepup-admin/)
6
+ [![licence](https://img.shields.io/pypi/l/keepup-admin)](LICENSE)
7
+
8
+ [Project page](https://fedorsergeev.github.io/keepup-admin/) ·
9
+ [Package](https://pypi.org/project/keepup-admin/) ·
10
+ [Changes](CHANGELOG.md)
11
+
12
+ Four badges, not six checks. These answer what somebody deciding whether to
13
+ install asks: does it run on my Python, is there a known vulnerability in what
14
+ it pulls in, what will I get, and may I use it. `quality`, `build` and `codeql`
15
+ are housekeeping and live on the Actions page -- six badges in a row stop being
16
+ read at all.
17
+
18
+ A badge shows; it does not require. What keeps a red check from being merged is
19
+ a branch rule, not a picture -- see `doc/keepup.md`.
20
+
3
21
  An admin framework over FastAPI. You give it settings; it gives you an
4
22
  application with a panel, a plugin runtime, users, a database layer, an audit,
5
23
  metrics and coordination between replicas already wired.
@@ -27,7 +45,7 @@ so the import name is free.
27
45
  | **Panel** | the shell, visual themes, and the catalogue of sections with role grants |
28
46
  | **Users** | local accounts, roles, panel sessions, sign-in through an external provider |
29
47
  | **Database** | a SQLAlchemy layer with a pool, table declarations for two dialects |
30
- | **Replicas** | distributed locks, leader election, a message bus, a cluster registry |
48
+ | **Replicas** | distributed locks, a message bus, a cluster registry, performance settings in one place |
31
49
  | **Observability** | audit of incoming requests, an event log, metrics, log shipping |
32
50
 
33
51
  ## What it does not decide for you
@@ -62,13 +80,26 @@ between replicas needs.
62
80
  ## Tests
63
81
 
64
82
  ```bash
65
- ci/tests/keepup.sh # the framework's own suite, needs no application
83
+ pip install -e ".[test]"
84
+ pytest tests/
66
85
  ```
67
86
 
68
- The suite runs against SQLite with no network and holds a coverage floor. The
69
- plugin runtime is the least covered part of the framework, and deliberately so
70
- for now: the test that exercises it hardest stands up a real application's
71
- plugins and therefore lives with that application. See `doc/keepup.md`.
87
+ **Install first, then test.** Pointing pytest at the checkout does not work on
88
+ its own: `import keepup` has to resolve, and the directory a clone lands in is
89
+ called `keepup-admin`, so nothing is named `keepup` until the package is
90
+ installed. The `test` extra carries what the suite needs beyond the package —
91
+ notably `cryptography`, which the framework itself never imports and the OIDC
92
+ tests sign tokens with.
93
+
94
+ The suite runs against SQLite with no network, needs no application beside the
95
+ package, and holds a coverage floor declared in `pyproject.toml`. Checks that
96
+ genuinely need a consumer of the framework find one by trait and skip with a
97
+ reason when there is none, so a skip here always says what is missing.
98
+
99
+ Every one of these runs on push and on a pull request; see `.github/workflows/`.
100
+ `security.yml` also runs weekly, because that check goes red without anybody
101
+ touching the repository — an advisory gets published against a version that was
102
+ fine yesterday.
72
103
 
73
104
  ## Licence
74
105
 
@@ -17,7 +17,6 @@ recorded says so by name -- ``configure(redaction=keep_as_is)``.
17
17
  import asyncio
18
18
  import json
19
19
  import logging
20
- import os
21
20
  import time
22
21
  from contextlib import asynccontextmanager
23
22
  from datetime import datetime
@@ -26,8 +25,8 @@ from typing import Any, Dict, Optional
26
25
  from sqlalchemy import CheckConstraint, Column, DateTime, Index, Integer, Text, UniqueConstraint
27
26
  from sqlalchemy.dialects import postgresql
28
27
 
29
- from keepup import tables
30
- from keepup.db import DatabaseManager, DatabaseManagerV2, db_config
28
+ from keepup import retention, tables
29
+ from keepup.db import DatabaseManagerV2, db_config
31
30
  from keepup.instance import get_instance_id
32
31
 
33
32
  #: What an application may import from this module. Everything else is
@@ -132,6 +131,15 @@ incoming_requests_lock = asyncio.Lock()
132
131
 
133
132
  BUFFER_FLUSH_INTERVAL = 125
134
133
  BUFFER_MAX_SIZE = 100
134
+ #: A request running this long is written without its outcome (keepup-41): the
135
+ #: process that would have finished it has most likely gone.
136
+ IN_FLIGHT_STALE_SECONDS = 3600
137
+
138
+
139
+ def _as_datetime(value) -> datetime:
140
+ if isinstance(value, str):
141
+ return datetime.fromisoformat(value.replace('Z', ''))
142
+ return value
135
143
 
136
144
 
137
145
  class IncomingRequestLogger:
@@ -211,104 +219,86 @@ class IncomingRequestLogger:
211
219
  return True
212
220
 
213
221
  @staticmethod
214
- async def flush_buffer() -> int:
215
- """Write the buffered requests to the database."""
216
- from keepup.db import DatabaseManager
217
-
222
+ async def flush_buffer(include_in_flight: bool = False, now: Optional[datetime] = None) -> int:
223
+ """Write the finished requests of the buffer to the database.
224
+
225
+ A request still running stays in the buffer (keepup-41): written by a
226
+ timed flush and dropped from the buffer, it could no longer receive its
227
+ outcome -- end_request found nothing, logged "not found", and the table
228
+ kept a request with no status. Two exceptions: a request running longer
229
+ than IN_FLIGHT_STALE_SECONDS is written without an outcome, since the
230
+ process that would finish it has most likely gone and the buffer must
231
+ not grow forever; and at shutdown (``include_in_flight``) everything is
232
+ written -- nothing will finish afterwards.
233
+ """
234
+ now = now or datetime.utcnow()
235
+ # The buffer lock is taken by the start and the end of every request, so
236
+ # it is held only to pick the rows and take them out -- never across the
237
+ # write, which runs in a worker thread and may take as long as the
238
+ # database does (keepup-44).
218
239
  async with incoming_requests_lock:
219
- if not incoming_requests_buffer:
220
- return 0
221
-
222
- requests_to_insert = list(incoming_requests_buffer.values())
223
- inserted_count = 0
224
-
225
- try:
226
- values = []
227
- for req in requests_to_insert:
228
- values.append((
229
- req['instance_id'],
230
- req['method'],
231
- req['endpoint'],
232
- req['host'],
233
- json.dumps(req['request_data']) if req['request_data'] else None,
234
- req['request_start_at'],
235
- req.get('request_end_at'),
236
- req.get('duration_ms'),
237
- req.get('http_status'),
238
- json.dumps(req.get('response_data')) if req.get('response_data') else None,
239
- req.get('error_message'),
240
- req['created_at']
241
- ))
242
-
243
- if values:
244
- if db_config.is_postgres():
245
- query = '''
246
- INSERT INTO incoming_requests
247
- (instance_id, method, endpoint, host, request_data, request_start_at,
248
- request_end_at, duration_ms, http_status, response_data, error_message, created_at)
249
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
250
- ON CONFLICT (instance_id, method, endpoint, request_start_at)
251
- DO NOTHING
252
- '''
253
- else:
254
- query = '''
255
- INSERT OR IGNORE INTO incoming_requests
256
- (instance_id, method, endpoint, host, request_data, request_start_at,
257
- request_end_at, duration_ms, http_status, response_data, error_message, created_at)
258
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
259
- '''
260
-
261
- if db_config.is_postgres():
262
- conn = DatabaseManager.get_connection()
263
- cursor = conn.cursor()
264
- try:
265
- cursor.executemany(query, values)
266
- inserted_count = cursor.rowcount
267
- conn.commit()
268
- finally:
269
- cursor.close()
270
- conn.close()
271
- else:
272
- conn = DatabaseManager.get_connection()
273
- cursor = conn.cursor()
274
- try:
275
- cursor.executemany(query, values)
276
- inserted_count = cursor.rowcount
277
- conn.commit()
278
- finally:
279
- cursor.close()
280
- conn.close()
281
-
282
- if inserted_count > 0:
283
- inserted_ids = []
284
- for req_id, req_data in incoming_requests_buffer.items():
285
- for v in values:
286
- if (req_data['instance_id'] == v[0] and
287
- req_data['method'] == v[1] and
288
- req_data['endpoint'] == v[2] and
289
- req_data['request_start_at'] == v[5]):
290
- inserted_ids.append(req_id)
291
- break
292
-
293
- for req_id in inserted_ids:
294
- incoming_requests_buffer.pop(req_id, None)
295
-
296
- logger.info(f"Flushed {inserted_count} incoming requests to database")
297
-
298
- except Exception as e:
299
- logger.error(f"Error flushing incoming requests buffer: {str(e)}")
300
-
301
- return inserted_count
240
+ chosen = {
241
+ request_id: req for request_id, req in incoming_requests_buffer.items()
242
+ if include_in_flight or 'request_end_at' in req
243
+ or (now - _as_datetime(req['request_start_at'])).total_seconds()
244
+ >= IN_FLIGHT_STALE_SECONDS
245
+ }
246
+ for request_id in chosen:
247
+ incoming_requests_buffer.pop(request_id, None)
248
+ if not chosen:
249
+ return 0
250
+
251
+ rows = [{
252
+ 'instance_id': req['instance_id'],
253
+ 'method': req['method'],
254
+ 'endpoint': req['endpoint'],
255
+ 'host': req['host'],
256
+ 'request_data': json.dumps(req['request_data']) if req['request_data'] else None,
257
+ 'request_start_at': req['request_start_at'],
258
+ 'request_end_at': req.get('request_end_at'),
259
+ 'duration_ms': req.get('duration_ms'),
260
+ 'http_status': req.get('http_status'),
261
+ 'response_data': (json.dumps(req.get('response_data'))
262
+ if req.get('response_data') else None),
263
+ 'error_message': req.get('error_message'),
264
+ 'created_at': req['created_at'],
265
+ } for req in chosen.values()]
266
+ columns = ("instance_id, method, endpoint, host, request_data, request_start_at, "
267
+ "request_end_at, duration_ms, http_status, response_data, error_message, "
268
+ "created_at")
269
+ placeholders = ", ".join(":" + c.strip() for c in columns.split(","))
270
+ if db_config.is_postgres():
271
+ query = (f"INSERT INTO incoming_requests ({columns}) VALUES ({placeholders}) "
272
+ f"ON CONFLICT (instance_id, method, endpoint, request_start_at) DO NOTHING")
273
+ else:
274
+ query = f"INSERT OR IGNORE INTO incoming_requests ({columns}) VALUES ({placeholders})"
275
+
276
+ try:
277
+ inserted_count = await DatabaseManagerV2.execute_many_async(query, rows)
278
+ except Exception as e:
279
+ # Back into the buffer: the next flush tries again. A request that
280
+ # somehow reappeared meanwhile keeps its newer entry.
281
+ logger.error(f"Error flushing incoming requests buffer: {str(e)}")
282
+ async with incoming_requests_lock:
283
+ for request_id, req in chosen.items():
284
+ incoming_requests_buffer.setdefault(request_id, req)
285
+ return 0
286
+
287
+ # Every chosen request is gone from the buffer, written or ignored as a
288
+ # duplicate alike: an ignored one is already in the table.
289
+ if inserted_count:
290
+ logger.info(f"Flushed {inserted_count} incoming requests to database")
291
+ return inserted_count
302
292
 
303
293
 
304
294
  #: What PostgreSQL enforces with named constraints, SQLite held as a unique
305
295
  #: index of the same name and no checks at all; each dialect keeps what it had.
306
296
  # --- how long a row lives ----------------------------------------------------
307
297
 
308
- #: How many days an audit row is kept. The table used to have no sweep at all,
309
- #: while metric snapshots and application events both had one: it grew for as
310
- #: long as the deployment ran, and every row in it was a second place a value
311
- #: from a query string lived.
298
+ #: How many days an audit row is kept. The table used to have no sweep at all:
299
+ #: it grew for as long as the deployment ran, and every row in it was a second
300
+ #: place a value from a query string lived. The event log is swept the same way
301
+ #: (keepup/retention.py).
312
302
  DEFAULT_AUDIT_RETENTION_DAYS = 30
313
303
  #: How often the sweep runs.
314
304
  AUDIT_SWEEP_INTERVAL_SECONDS = 3600
@@ -325,35 +315,12 @@ AUDIT_RETENTION_LOCK = "incoming_requests_retention"
325
315
 
326
316
 
327
317
  def retention_days():
328
- """How many days rows are kept, from the environment or the default.
329
-
330
- Returns:
331
- A positive number of days; rubbish in the variable means the default
332
- and a line in the log, because a sweep that switched itself off
333
- silently would be worse than one that swept too much.
334
- """
335
- raw = os.getenv("AUDIT_RETENTION_DAYS")
336
- if raw in (None, ""):
337
- return DEFAULT_AUDIT_RETENTION_DAYS
338
- try:
339
- value = int(raw)
340
- except (TypeError, ValueError):
341
- logger.warning("AUDIT_RETENTION_DAYS=%r is not a number, using %s",
342
- raw, DEFAULT_AUDIT_RETENTION_DAYS)
343
- return DEFAULT_AUDIT_RETENTION_DAYS
344
- if value <= 0:
345
- logger.warning("AUDIT_RETENTION_DAYS=%s must be positive, using %s",
346
- value, DEFAULT_AUDIT_RETENTION_DAYS)
347
- return DEFAULT_AUDIT_RETENTION_DAYS
348
- return value
318
+ """How many days audit rows are kept (``AUDIT_RETENTION_DAYS``, 30 by default)."""
319
+ return retention.retention_days("AUDIT_RETENTION_DAYS", DEFAULT_AUDIT_RETENTION_DAYS)
349
320
 
350
321
 
351
322
  def purge_old_requests(days=None, now=None):
352
- """Delete audit rows older than the retention period.
353
-
354
- The boundary is computed here rather than in the statement: the interval
355
- syntax differs between the engines, and a query written for one does not
356
- run on the other.
323
+ """Delete audit rows older than the retention period, in chunks.
357
324
 
358
325
  Args:
359
326
  days: how many days to keep; read from the environment when omitted.
@@ -362,27 +329,10 @@ def purge_old_requests(days=None, now=None):
362
329
  Returns:
363
330
  How many rows were deleted.
364
331
  """
365
- from datetime import timedelta
366
-
367
- keep_days = days if days is not None else retention_days()
368
- cutoff = (now or datetime.utcnow()) - timedelta(days=keep_days)
369
-
370
- removed = 0
371
- for _ in range(AUDIT_MAX_CHUNKS_PER_PASS):
372
- rows = DatabaseManagerV2.execute(
373
- "SELECT id FROM incoming_requests WHERE created_at < :cutoff "
374
- "ORDER BY created_at ASC LIMIT :limit",
375
- {"cutoff": cutoff, "limit": AUDIT_DELETE_CHUNK_ROWS})
376
- if not rows:
377
- break
378
- ids = {f"i{index}": row["id"] for index, row in enumerate(rows)}
379
- placeholders = ", ".join(f":{key}" for key in ids)
380
- DatabaseManagerV2.execute_commit(
381
- f"DELETE FROM incoming_requests WHERE id IN ({placeholders})", ids)
382
- removed += len(rows)
383
- if len(rows) < AUDIT_DELETE_CHUNK_ROWS:
384
- break
385
- return removed
332
+ return retention.purge_older_than(
333
+ "incoming_requests", "created_at",
334
+ days if days is not None else retention_days(),
335
+ AUDIT_DELETE_CHUNK_ROWS, AUDIT_MAX_CHUNKS_PER_PASS, now=now)
386
336
 
387
337
 
388
338
  async def audit_retention_background():
@@ -16,7 +16,7 @@ and silently getting local sign-in is an open door where nobody looked for one.
16
16
  import os
17
17
  from enum import Enum
18
18
  from pathlib import Path
19
- from typing import Any, Dict, Optional
19
+ from typing import Any, Dict
20
20
 
21
21
  import yaml
22
22
  from pydantic import BaseModel, ConfigDict, Field