seren-sinew 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.
- seren_sinew-1.0.0/.gitignore +11 -0
- seren_sinew-1.0.0/PKG-INFO +82 -0
- seren_sinew-1.0.0/README.md +56 -0
- seren_sinew-1.0.0/SerenSinew.pyproj +51 -0
- seren_sinew-1.0.0/pyproject.toml +64 -0
- seren_sinew-1.0.0/seren_sinew/__init__.py +31 -0
- seren_sinew-1.0.0/seren_sinew/_version.py +24 -0
- seren_sinew-1.0.0/seren_sinew/request_log.py +212 -0
- seren_sinew-1.0.0/seren_sinew.egg-info/PKG-INFO +82 -0
- seren_sinew-1.0.0/seren_sinew.egg-info/SOURCES.txt +15 -0
- seren_sinew-1.0.0/seren_sinew.egg-info/dependency_links.txt +1 -0
- seren_sinew-1.0.0/seren_sinew.egg-info/requires.txt +6 -0
- seren_sinew-1.0.0/seren_sinew.egg-info/scm_file_list.json +11 -0
- seren_sinew-1.0.0/seren_sinew.egg-info/scm_version.json +8 -0
- seren_sinew-1.0.0/seren_sinew.egg-info/top_level.txt +1 -0
- seren_sinew-1.0.0/setup.cfg +4 -0
- seren_sinew-1.0.0/tests/test_request_log.py +146 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# setuptools-scm writes this at build time - NEVER commit it (the rest of the
|
|
2
|
+
# family gitignores it too; committing it pins a stale version into the tree).
|
|
3
|
+
seren_sinew/_version.py
|
|
4
|
+
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
build/
|
|
8
|
+
dist/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
.vs/
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: seren-sinew
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: The connective tissue of the Seren stack: shared runtime plumbing (request logging now; cluster client / discovery / DTOs later).
|
|
5
|
+
License: GPL-3.0-or-later
|
|
6
|
+
Project-URL: Homepage, https://github.com/ChadRoesler/SerenSinew
|
|
7
|
+
Project-URL: Repository, https://github.com/ChadRoesler/SerenSinew
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/ChadRoesler/SerenSinew/issues
|
|
9
|
+
Keywords: seren,fastapi,starlette,logging,middleware
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: FastAPI
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: System :: Logging
|
|
18
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: starlette>=0.37
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
25
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
26
|
+
|
|
27
|
+
# SerenSinew
|
|
28
|
+
|
|
29
|
+
The connective tissue of the Seren stack.
|
|
30
|
+
|
|
31
|
+
If [SerenMeninges](https://github.com/ChadRoesler/SerenMeninges) is the
|
|
32
|
+
**membrane** - the UI shell, auth, config, credentials every service wears -
|
|
33
|
+
then Sinew is the **tendon**: the cross-cutting *runtime* plumbing every Seren
|
|
34
|
+
web service repeats. One copy, so a fix lands everywhere at once.
|
|
35
|
+
|
|
36
|
+
## What's in it (today)
|
|
37
|
+
|
|
38
|
+
**Request logging.** Drop-in middleware that logs every HTTP request as
|
|
39
|
+
`<client> <METHOD> <path> -> <status> (<ms>ms)` - INFO for 2xx/3xx, WARNING for
|
|
40
|
+
4xx (and slow 2xx), ERROR with a full traceback for 5xx. Goes to **both** stderr
|
|
41
|
+
(so `journalctl` catches it) **and** a rotating file the running user owns at
|
|
42
|
+
`~/seren-logs/<service>-requests.log`, so anyone debugging can `tail` it without
|
|
43
|
+
sudo.
|
|
44
|
+
|
|
45
|
+
It's parameterized, not hardcoded - `service_name` picks the logger name and
|
|
46
|
+
log filename, `env_prefix` picks the env-var namespace:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from seren_sinew.request_log import RequestLoggingMiddleware
|
|
50
|
+
|
|
51
|
+
# Mount OUTERMOST - before auth - so 401s get logged too.
|
|
52
|
+
app.add_middleware(BearerAuthMiddleware, expected_token=token) # inner
|
|
53
|
+
app.add_middleware( # outer
|
|
54
|
+
RequestLoggingMiddleware,
|
|
55
|
+
service_name="seren-observatory",
|
|
56
|
+
env_prefix="SEREN_AGENT", # -> SEREN_AGENT_LOG_LEVEL / SEREN_AGENT_LOG_QUERY
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Knobs (per `env_prefix`):
|
|
61
|
+
|
|
62
|
+
| env var | effect |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `<PREFIX>_LOG_LEVEL` | `INFO` (default) `\| DEBUG \| WARNING \| ERROR` |
|
|
65
|
+
| `<PREFIX>_LOG_QUERY` | `1` to append `?query` to the logged path (off by default - query strings can carry tokens / PII) |
|
|
66
|
+
|
|
67
|
+
## What's coming
|
|
68
|
+
|
|
69
|
+
The Python landing pad for the cluster client / discovery / DTOs when Lodestar
|
|
70
|
+
(RuntimeHost) and Workbench port over from C#. Sinew is where the connective
|
|
71
|
+
runtime code goes; Meninges stays the membrane.
|
|
72
|
+
|
|
73
|
+
## Install
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
pip install seren-sinew
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Light by design - depends only on `starlette` (already in every leaf via
|
|
80
|
+
FastAPI), so it stays FastAPI-agnostic and adds nothing to a real install.
|
|
81
|
+
|
|
82
|
+
GPL-3.0-or-later.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# SerenSinew
|
|
2
|
+
|
|
3
|
+
The connective tissue of the Seren stack.
|
|
4
|
+
|
|
5
|
+
If [SerenMeninges](https://github.com/ChadRoesler/SerenMeninges) is the
|
|
6
|
+
**membrane** - the UI shell, auth, config, credentials every service wears -
|
|
7
|
+
then Sinew is the **tendon**: the cross-cutting *runtime* plumbing every Seren
|
|
8
|
+
web service repeats. One copy, so a fix lands everywhere at once.
|
|
9
|
+
|
|
10
|
+
## What's in it (today)
|
|
11
|
+
|
|
12
|
+
**Request logging.** Drop-in middleware that logs every HTTP request as
|
|
13
|
+
`<client> <METHOD> <path> -> <status> (<ms>ms)` - INFO for 2xx/3xx, WARNING for
|
|
14
|
+
4xx (and slow 2xx), ERROR with a full traceback for 5xx. Goes to **both** stderr
|
|
15
|
+
(so `journalctl` catches it) **and** a rotating file the running user owns at
|
|
16
|
+
`~/seren-logs/<service>-requests.log`, so anyone debugging can `tail` it without
|
|
17
|
+
sudo.
|
|
18
|
+
|
|
19
|
+
It's parameterized, not hardcoded - `service_name` picks the logger name and
|
|
20
|
+
log filename, `env_prefix` picks the env-var namespace:
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from seren_sinew.request_log import RequestLoggingMiddleware
|
|
24
|
+
|
|
25
|
+
# Mount OUTERMOST - before auth - so 401s get logged too.
|
|
26
|
+
app.add_middleware(BearerAuthMiddleware, expected_token=token) # inner
|
|
27
|
+
app.add_middleware( # outer
|
|
28
|
+
RequestLoggingMiddleware,
|
|
29
|
+
service_name="seren-observatory",
|
|
30
|
+
env_prefix="SEREN_AGENT", # -> SEREN_AGENT_LOG_LEVEL / SEREN_AGENT_LOG_QUERY
|
|
31
|
+
)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Knobs (per `env_prefix`):
|
|
35
|
+
|
|
36
|
+
| env var | effect |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| `<PREFIX>_LOG_LEVEL` | `INFO` (default) `\| DEBUG \| WARNING \| ERROR` |
|
|
39
|
+
| `<PREFIX>_LOG_QUERY` | `1` to append `?query` to the logged path (off by default - query strings can carry tokens / PII) |
|
|
40
|
+
|
|
41
|
+
## What's coming
|
|
42
|
+
|
|
43
|
+
The Python landing pad for the cluster client / discovery / DTOs when Lodestar
|
|
44
|
+
(RuntimeHost) and Workbench port over from C#. Sinew is where the connective
|
|
45
|
+
runtime code goes; Meninges stays the membrane.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
pip install seren-sinew
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Light by design - depends only on `starlette` (already in every leaf via
|
|
54
|
+
FastAPI), so it stays FastAPI-agnostic and adds nothing to a real install.
|
|
55
|
+
|
|
56
|
+
GPL-3.0-or-later.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003" ToolsVersion="4.0">
|
|
2
|
+
<PropertyGroup>
|
|
3
|
+
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
|
|
4
|
+
<SchemaVersion>2.0</SchemaVersion>
|
|
5
|
+
<ProjectGuid>ae4e1ede-7cfb-4d56-9d60-019aaea8c812</ProjectGuid>
|
|
6
|
+
<ProjectHome>.</ProjectHome>
|
|
7
|
+
<StartupFile>
|
|
8
|
+
</StartupFile>
|
|
9
|
+
<SearchPath>
|
|
10
|
+
</SearchPath>
|
|
11
|
+
<WorkingDirectory>.</WorkingDirectory>
|
|
12
|
+
<OutputPath>.</OutputPath>
|
|
13
|
+
<Name>SerenSinew</Name>
|
|
14
|
+
<RootNamespace>SerenSinew</RootNamespace>
|
|
15
|
+
</PropertyGroup>
|
|
16
|
+
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
|
|
17
|
+
<DebugSymbols>true</DebugSymbols>
|
|
18
|
+
<EnableUnmanagedDebugging>false</EnableUnmanagedDebugging>
|
|
19
|
+
</PropertyGroup>
|
|
20
|
+
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
|
|
21
|
+
<DebugSymbols>true</DebugSymbols>
|
|
22
|
+
<EnableUnmanagedDebugging>false</EnableUnmanagedDebugging>
|
|
23
|
+
</PropertyGroup>
|
|
24
|
+
<ItemGroup>
|
|
25
|
+
<Compile Include="seren_sinew\request_log.py" />
|
|
26
|
+
<Compile Include="seren_sinew\_version.py" />
|
|
27
|
+
<Compile Include="seren_sinew\__init__.py" />
|
|
28
|
+
<Compile Include="tests\test_request_log.py" />
|
|
29
|
+
</ItemGroup>
|
|
30
|
+
<ItemGroup>
|
|
31
|
+
<Folder Include="seren_sinew\" />
|
|
32
|
+
<Folder Include="seren_sinew\__pycache__\" />
|
|
33
|
+
<Folder Include="tests\" />
|
|
34
|
+
<Folder Include="tests\__pycache__\" />
|
|
35
|
+
</ItemGroup>
|
|
36
|
+
<ItemGroup>
|
|
37
|
+
<Content Include="seren_sinew\__pycache__\request_log.cpython-311.pyc" />
|
|
38
|
+
<Content Include="seren_sinew\__pycache__\_version.cpython-311.pyc" />
|
|
39
|
+
<Content Include="seren_sinew\__pycache__\__init__.cpython-311.pyc" />
|
|
40
|
+
<Content Include="tests\__pycache__\test_request_log.cpython-311-pytest-9.0.3.pyc" />
|
|
41
|
+
</ItemGroup>
|
|
42
|
+
<Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\Python Tools\Microsoft.PythonTools.targets" />
|
|
43
|
+
<!-- Uncomment the CoreCompile target to enable the Build command in
|
|
44
|
+
Visual Studio and specify your pre- and post-build commands in
|
|
45
|
+
the BeforeBuild and AfterBuild targets below. -->
|
|
46
|
+
<!--<Target Name="CoreCompile" />-->
|
|
47
|
+
<Target Name="BeforeBuild">
|
|
48
|
+
</Target>
|
|
49
|
+
<Target Name="AfterBuild">
|
|
50
|
+
</Target>
|
|
51
|
+
</Project>
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# ========================================================================
|
|
2
|
+
# seren-sinew - the Seren shared runtime plumbing
|
|
3
|
+
# Chad owns release.yml / Trusted Publishing / dependabot wiring; this is
|
|
4
|
+
# just the package shape: deps minimal, starlette-only (already in every
|
|
5
|
+
# leaf via FastAPI, so it adds nothing to the real install).
|
|
6
|
+
# ========================================================================
|
|
7
|
+
[build-system]
|
|
8
|
+
requires = ["setuptools>=64", "setuptools-scm>=8"]
|
|
9
|
+
build-backend = "setuptools.build_meta"
|
|
10
|
+
|
|
11
|
+
[project]
|
|
12
|
+
name = "seren-sinew"
|
|
13
|
+
description = "The connective tissue of the Seren stack: shared runtime plumbing (request logging now; cluster client / discovery / DTOs later)."
|
|
14
|
+
readme = "README.md"
|
|
15
|
+
license = { text = "GPL-3.0-or-later" }
|
|
16
|
+
requires-python = ">=3.10"
|
|
17
|
+
dynamic = ["version"]
|
|
18
|
+
keywords = ["seren", "fastapi", "starlette", "logging", "middleware"]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Framework :: FastAPI",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3.10",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Topic :: System :: Logging",
|
|
28
|
+
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
# Light, like Meninges: starlette only. It's already in every leaf via
|
|
32
|
+
# FastAPI, so depending on it costs nothing at install time and keeps Sinew
|
|
33
|
+
# FastAPI-agnostic (we import starlette.requests.Request, not fastapi.Request).
|
|
34
|
+
dependencies = [
|
|
35
|
+
"starlette>=0.37",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.23", "httpx>=0.27"]
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/ChadRoesler/SerenSinew"
|
|
43
|
+
Repository = "https://github.com/ChadRoesler/SerenSinew"
|
|
44
|
+
"Bug Tracker" = "https://github.com/ChadRoesler/SerenSinew/issues"
|
|
45
|
+
|
|
46
|
+
[tool.setuptools]
|
|
47
|
+
packages = ["seren_sinew"]
|
|
48
|
+
|
|
49
|
+
[tool.setuptools_scm]
|
|
50
|
+
# Inner project dir under the git root, mirroring the rest of the family
|
|
51
|
+
# (the package lives at SerenSinew/SerenSinew/seren_sinew, git root is SerenSinew/).
|
|
52
|
+
root = ".."
|
|
53
|
+
# scm writes the version into the package (gitignored); __init__ reads it via
|
|
54
|
+
# importlib.metadata at runtime, with this file as the source-checkout fallback.
|
|
55
|
+
version_file = "seren_sinew/_version.py"
|
|
56
|
+
# Strip the leading 'v' so "v1.2.3" -> "1.2.3".
|
|
57
|
+
tag_regex = "^v(?P<version>[0-9]+\\.[0-9]+\\.[0-9]+.*)$"
|
|
58
|
+
# Used when no git tags exist (local dev, fresh clone, make test).
|
|
59
|
+
# SETUPTOOLS_SCM_PRETEND_VERSION overrides this when set (CI release builds).
|
|
60
|
+
fallback_version = "0.0.0.dev0"
|
|
61
|
+
|
|
62
|
+
[tool.pytest.ini_options]
|
|
63
|
+
testpaths = ["tests"]
|
|
64
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""seren_sinew - the connective-tissue runtime plumbing of the Seren stack.
|
|
2
|
+
|
|
3
|
+
Sibling to seren_meninges (the UI / auth / config baseplate). Where Meninges
|
|
4
|
+
is the membrane, Sinew is the tendon: cross-cutting *runtime* concerns shared
|
|
5
|
+
by every Seren web service. First brick: request logging. Future home for the
|
|
6
|
+
cluster client / discovery / DTOs once Lodestar ports from C# to Python.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .request_log import (
|
|
11
|
+
RequestLoggingMiddleware,
|
|
12
|
+
get_logger,
|
|
13
|
+
setup_request_logger,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
try: # the setuptools-scm build artifact (gitignored); present in installed wheels
|
|
17
|
+
from ._version import version as __version__
|
|
18
|
+
except Exception: # source checkout without a build -> metadata, then dev fallback
|
|
19
|
+
try:
|
|
20
|
+
from importlib.metadata import version as _v
|
|
21
|
+
|
|
22
|
+
__version__ = _v("seren-sinew")
|
|
23
|
+
except Exception:
|
|
24
|
+
__version__ = "0.0.0.dev0"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"RequestLoggingMiddleware",
|
|
28
|
+
"setup_request_logger",
|
|
29
|
+
"get_logger",
|
|
30
|
+
"__version__",
|
|
31
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '1.0.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 0, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = 'g5549343f4'
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""
|
|
2
|
+
seren_sinew.request_log
|
|
3
|
+
========================================================================
|
|
4
|
+
|
|
5
|
+
The request-logging middleware every Seren web service mounts. The same idea
|
|
6
|
+
that lived more than once - the Observatory's request_log.py and the C#
|
|
7
|
+
RuntimeHost's SerenRequestLogger (and, soon, Workbench) - collapsed into ONE
|
|
8
|
+
parameterized copy so a fix lands everywhere at once. This is the logging twin
|
|
9
|
+
of seren_meninges.auth: ops-sensitive, identical-in-spirit across services,
|
|
10
|
+
exactly the thing you want a single source of truth for. Meninges is the
|
|
11
|
+
membrane; Sinew is the tendon.
|
|
12
|
+
|
|
13
|
+
WHAT IT DOES
|
|
14
|
+
Logs every HTTP request as: "<client> <METHOD> <path> -> <status> (<ms>ms)".
|
|
15
|
+
Level by outcome: 2xx/3xx INFO, 4xx WARNING, 5xx ERROR (with full
|
|
16
|
+
traceback), and any 2xx slower than 1s gets a WARNING [slow] tag. Output
|
|
17
|
+
goes to BOTH stderr (journalctl picks it up) AND a rotating file the
|
|
18
|
+
running user owns at <log_dir>/<service>-requests.log.
|
|
19
|
+
|
|
20
|
+
WHY A FILE THE USER OWNS
|
|
21
|
+
'sudo journalctl -u seren-*' wants a password every time. Anyone debugging
|
|
22
|
+
needs the log NOW, not "go set up sudoers." A plain file under ~/seren-logs/
|
|
23
|
+
is the consistent, no-privileges UX across the whole family.
|
|
24
|
+
|
|
25
|
+
WHY NOT uvicorn's access log
|
|
26
|
+
stderr-only (no file), fixed format (no duration ms, no 5xx traceback).
|
|
27
|
+
|
|
28
|
+
PARAMETERIZED, NOT HARDCODED
|
|
29
|
+
service_name drives the logger name and the default log filename; env_prefix
|
|
30
|
+
drives the env-var namespace so each service reads its own knobs:
|
|
31
|
+
{env_prefix}_LOG_LEVEL - INFO (default) | DEBUG | WARNING | ERROR
|
|
32
|
+
{env_prefix}_LOG_QUERY - "1" to append ?query to the path (off by
|
|
33
|
+
default: query strings can carry tokens / PII)
|
|
34
|
+
Observatory keeps its EXACT prior contract by passing env_prefix="SEREN_AGENT"
|
|
35
|
+
(-> SEREN_AGENT_LOG_LEVEL / SEREN_AGENT_LOG_QUERY, and "seren-observatory"
|
|
36
|
+
still resolves to observatory-requests.log).
|
|
37
|
+
|
|
38
|
+
WHY ASCII '->' AND NOT '->' (the unicode arrow)
|
|
39
|
+
The original line used a literal unicode arrow. Sinew is cross-platform - it
|
|
40
|
+
runs on the Windows dev box too, where a non-UTF-8 stderr codepage makes the
|
|
41
|
+
StreamHandler choke on a non-ASCII glyph (we've eaten that crash before in
|
|
42
|
+
the consolidator). ASCII '->' is the safe floor; the file handler is utf-8
|
|
43
|
+
either way, but the console handler is the one that bites.
|
|
44
|
+
|
|
45
|
+
MIDDLEWARE ORDER (load-bearing)
|
|
46
|
+
Mount this OUTERMOST - before auth - so auth-rejected (401) requests are
|
|
47
|
+
logged too ("is the dashboard 401ing or 500ing?" is half the debug battle).
|
|
48
|
+
Starlette runs middleware LIFO on the way in, so add auth FIRST and this
|
|
49
|
+
SECOND: auth ends up inner, logging outer.
|
|
50
|
+
|
|
51
|
+
Depends on starlette only (already in every leaf via FastAPI), staying
|
|
52
|
+
FastAPI-agnostic like the rest of Sinew.
|
|
53
|
+
"""
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import logging
|
|
57
|
+
import logging.handlers
|
|
58
|
+
import os
|
|
59
|
+
import time
|
|
60
|
+
import traceback
|
|
61
|
+
from pathlib import Path
|
|
62
|
+
from typing import Optional, Union
|
|
63
|
+
|
|
64
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
65
|
+
from starlette.requests import Request
|
|
66
|
+
from starlette.responses import Response
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _default_log_filename(service_name: str) -> str:
|
|
70
|
+
"""`seren-observatory` -> `observatory-requests.log`. Strips a leading
|
|
71
|
+
`seren-` so the family's filenames read cleanly; falls back to the raw
|
|
72
|
+
name if stripping would leave nothing."""
|
|
73
|
+
stem = service_name.removeprefix("seren-") or service_name
|
|
74
|
+
return f"{stem}-requests.log"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def setup_request_logger(
|
|
78
|
+
service_name: str,
|
|
79
|
+
*,
|
|
80
|
+
log_dir: Optional[Union[str, Path]] = None,
|
|
81
|
+
log_filename: Optional[str] = None,
|
|
82
|
+
env_prefix: str = "SEREN",
|
|
83
|
+
level: Optional[str] = None,
|
|
84
|
+
backup_count: int = 7,
|
|
85
|
+
) -> logging.Logger:
|
|
86
|
+
"""Configure (once) and return the request logger for a service.
|
|
87
|
+
|
|
88
|
+
Idempotent: if the logger already has handlers it's returned as-is, so
|
|
89
|
+
repeated calls (app reload, several modules wanting the same sink) never
|
|
90
|
+
stack duplicate handlers.
|
|
91
|
+
|
|
92
|
+
Two handlers: a stderr StreamHandler (journalctl) and a midnight
|
|
93
|
+
TimedRotatingFileHandler at <log_dir>/<log_filename> keeping backup_count
|
|
94
|
+
days. If the dir isn't writable it falls back to stderr-only rather than
|
|
95
|
+
crashing the service.
|
|
96
|
+
"""
|
|
97
|
+
logger = logging.getLogger(f"{service_name}.requests")
|
|
98
|
+
if logger.handlers:
|
|
99
|
+
return logger # already configured - don't double up handlers
|
|
100
|
+
|
|
101
|
+
level_name = (level or os.environ.get(f"{env_prefix}_LOG_LEVEL", "INFO")).upper()
|
|
102
|
+
logger.setLevel(getattr(logging, level_name, logging.INFO))
|
|
103
|
+
logger.propagate = False # don't double-log via root
|
|
104
|
+
|
|
105
|
+
fmt = logging.Formatter(
|
|
106
|
+
"%(asctime)s [%(levelname)s] %(message)s",
|
|
107
|
+
datefmt="%Y-%m-%d %H:%M:%S",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
stream = logging.StreamHandler()
|
|
111
|
+
stream.setFormatter(fmt)
|
|
112
|
+
logger.addHandler(stream)
|
|
113
|
+
|
|
114
|
+
directory = Path(log_dir) if log_dir is not None else Path.home() / "seren-logs"
|
|
115
|
+
filename = log_filename or _default_log_filename(service_name)
|
|
116
|
+
try:
|
|
117
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
file_handler = logging.handlers.TimedRotatingFileHandler(
|
|
119
|
+
filename=directory / filename,
|
|
120
|
+
when="midnight",
|
|
121
|
+
backupCount=backup_count,
|
|
122
|
+
encoding="utf-8",
|
|
123
|
+
)
|
|
124
|
+
file_handler.setFormatter(fmt)
|
|
125
|
+
logger.addHandler(file_handler)
|
|
126
|
+
except OSError as e:
|
|
127
|
+
# Don't crash the service if the log dir isn't writable - degrade to
|
|
128
|
+
# stderr-only. Request lines still reach journalctl.
|
|
129
|
+
logger.warning(f"could not open file log at {directory}: {e}")
|
|
130
|
+
|
|
131
|
+
return logger
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def get_logger(service_name: str, **kwargs) -> logging.Logger:
|
|
135
|
+
"""Accessor for other modules that want to log to the same request sink.
|
|
136
|
+
Same keyword args as setup_request_logger; idempotent."""
|
|
137
|
+
return setup_request_logger(service_name, **kwargs)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
|
141
|
+
"""Logs every request with timing + status; captures 5xx tracebacks.
|
|
142
|
+
|
|
143
|
+
Mount OUTERMOST (before auth). Wire it as::
|
|
144
|
+
|
|
145
|
+
from seren_sinew.request_log import RequestLoggingMiddleware
|
|
146
|
+
app.add_middleware(BearerAuthMiddleware, expected_token=token) # inner
|
|
147
|
+
app.add_middleware( # outer
|
|
148
|
+
RequestLoggingMiddleware,
|
|
149
|
+
service_name="seren-observatory",
|
|
150
|
+
env_prefix="SEREN_AGENT",
|
|
151
|
+
)
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
app,
|
|
157
|
+
service_name: str,
|
|
158
|
+
*,
|
|
159
|
+
log_dir: Optional[Union[str, Path]] = None,
|
|
160
|
+
log_filename: Optional[str] = None,
|
|
161
|
+
env_prefix: str = "SEREN",
|
|
162
|
+
level: Optional[str] = None,
|
|
163
|
+
backup_count: int = 7,
|
|
164
|
+
):
|
|
165
|
+
super().__init__(app)
|
|
166
|
+
self._env_prefix = env_prefix
|
|
167
|
+
self._log = setup_request_logger(
|
|
168
|
+
service_name,
|
|
169
|
+
log_dir=log_dir,
|
|
170
|
+
log_filename=log_filename,
|
|
171
|
+
env_prefix=env_prefix,
|
|
172
|
+
level=level,
|
|
173
|
+
backup_count=backup_count,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
177
|
+
start = time.perf_counter()
|
|
178
|
+
method = request.method
|
|
179
|
+
path = request.url.path
|
|
180
|
+
# Query string off by default - it can carry tokens / PII. Opt in with
|
|
181
|
+
# {env_prefix}_LOG_QUERY=1 when you actually need it for debug.
|
|
182
|
+
if os.environ.get(f"{self._env_prefix}_LOG_QUERY") == "1" and request.url.query:
|
|
183
|
+
path = f"{path}?{request.url.query}"
|
|
184
|
+
client = request.client.host if request.client else "?"
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
response = await call_next(request)
|
|
188
|
+
duration_ms = int((time.perf_counter() - start) * 1000)
|
|
189
|
+
status = response.status_code
|
|
190
|
+
|
|
191
|
+
line = f"{client} {method} {path} -> {status} ({duration_ms}ms)"
|
|
192
|
+
if status >= 500:
|
|
193
|
+
self._log.error(line)
|
|
194
|
+
elif status >= 400:
|
|
195
|
+
self._log.warning(line)
|
|
196
|
+
elif duration_ms > 1000:
|
|
197
|
+
self._log.warning(f"{line} [slow]")
|
|
198
|
+
else:
|
|
199
|
+
self._log.info(line)
|
|
200
|
+
|
|
201
|
+
return response
|
|
202
|
+
|
|
203
|
+
except Exception as e:
|
|
204
|
+
# Unhandled exception escaped the route. Log the full traceback then
|
|
205
|
+
# re-raise so the framework's 500 handler still runs.
|
|
206
|
+
duration_ms = int((time.perf_counter() - start) * 1000)
|
|
207
|
+
tb = traceback.format_exc()
|
|
208
|
+
self._log.error(
|
|
209
|
+
f"{client} {method} {path} -> 500 EXCEPTION ({duration_ms}ms)\n"
|
|
210
|
+
f" {type(e).__name__}: {e}\n{tb}"
|
|
211
|
+
)
|
|
212
|
+
raise
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: seren-sinew
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: The connective tissue of the Seren stack: shared runtime plumbing (request logging now; cluster client / discovery / DTOs later).
|
|
5
|
+
License: GPL-3.0-or-later
|
|
6
|
+
Project-URL: Homepage, https://github.com/ChadRoesler/SerenSinew
|
|
7
|
+
Project-URL: Repository, https://github.com/ChadRoesler/SerenSinew
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/ChadRoesler/SerenSinew/issues
|
|
9
|
+
Keywords: seren,fastapi,starlette,logging,middleware
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: FastAPI
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: System :: Logging
|
|
18
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: starlette>=0.37
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
25
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
26
|
+
|
|
27
|
+
# SerenSinew
|
|
28
|
+
|
|
29
|
+
The connective tissue of the Seren stack.
|
|
30
|
+
|
|
31
|
+
If [SerenMeninges](https://github.com/ChadRoesler/SerenMeninges) is the
|
|
32
|
+
**membrane** - the UI shell, auth, config, credentials every service wears -
|
|
33
|
+
then Sinew is the **tendon**: the cross-cutting *runtime* plumbing every Seren
|
|
34
|
+
web service repeats. One copy, so a fix lands everywhere at once.
|
|
35
|
+
|
|
36
|
+
## What's in it (today)
|
|
37
|
+
|
|
38
|
+
**Request logging.** Drop-in middleware that logs every HTTP request as
|
|
39
|
+
`<client> <METHOD> <path> -> <status> (<ms>ms)` - INFO for 2xx/3xx, WARNING for
|
|
40
|
+
4xx (and slow 2xx), ERROR with a full traceback for 5xx. Goes to **both** stderr
|
|
41
|
+
(so `journalctl` catches it) **and** a rotating file the running user owns at
|
|
42
|
+
`~/seren-logs/<service>-requests.log`, so anyone debugging can `tail` it without
|
|
43
|
+
sudo.
|
|
44
|
+
|
|
45
|
+
It's parameterized, not hardcoded - `service_name` picks the logger name and
|
|
46
|
+
log filename, `env_prefix` picks the env-var namespace:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from seren_sinew.request_log import RequestLoggingMiddleware
|
|
50
|
+
|
|
51
|
+
# Mount OUTERMOST - before auth - so 401s get logged too.
|
|
52
|
+
app.add_middleware(BearerAuthMiddleware, expected_token=token) # inner
|
|
53
|
+
app.add_middleware( # outer
|
|
54
|
+
RequestLoggingMiddleware,
|
|
55
|
+
service_name="seren-observatory",
|
|
56
|
+
env_prefix="SEREN_AGENT", # -> SEREN_AGENT_LOG_LEVEL / SEREN_AGENT_LOG_QUERY
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Knobs (per `env_prefix`):
|
|
61
|
+
|
|
62
|
+
| env var | effect |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `<PREFIX>_LOG_LEVEL` | `INFO` (default) `\| DEBUG \| WARNING \| ERROR` |
|
|
65
|
+
| `<PREFIX>_LOG_QUERY` | `1` to append `?query` to the logged path (off by default - query strings can carry tokens / PII) |
|
|
66
|
+
|
|
67
|
+
## What's coming
|
|
68
|
+
|
|
69
|
+
The Python landing pad for the cluster client / discovery / DTOs when Lodestar
|
|
70
|
+
(RuntimeHost) and Workbench port over from C#. Sinew is where the connective
|
|
71
|
+
runtime code goes; Meninges stays the membrane.
|
|
72
|
+
|
|
73
|
+
## Install
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
pip install seren-sinew
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Light by design - depends only on `starlette` (already in every leaf via
|
|
80
|
+
FastAPI), so it stays FastAPI-agnostic and adds nothing to a real install.
|
|
81
|
+
|
|
82
|
+
GPL-3.0-or-later.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
.gitignore
|
|
2
|
+
README.md
|
|
3
|
+
SerenSinew.pyproj
|
|
4
|
+
pyproject.toml
|
|
5
|
+
seren_sinew/__init__.py
|
|
6
|
+
seren_sinew/_version.py
|
|
7
|
+
seren_sinew/request_log.py
|
|
8
|
+
seren_sinew.egg-info/PKG-INFO
|
|
9
|
+
seren_sinew.egg-info/SOURCES.txt
|
|
10
|
+
seren_sinew.egg-info/dependency_links.txt
|
|
11
|
+
seren_sinew.egg-info/requires.txt
|
|
12
|
+
seren_sinew.egg-info/scm_file_list.json
|
|
13
|
+
seren_sinew.egg-info/scm_version.json
|
|
14
|
+
seren_sinew.egg-info/top_level.txt
|
|
15
|
+
tests/test_request_log.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
seren_sinew
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Tests for seren_sinew.request_log.
|
|
2
|
+
|
|
3
|
+
Runs the middleware against a real (tiny) Starlette app via TestClient and
|
|
4
|
+
asserts on the actual rotating-file output - the realest shakedown without a
|
|
5
|
+
live server. Each test uses its own service_name so the module-level logger
|
|
6
|
+
registry doesn't bleed handlers across cases; an autouse fixture tears the
|
|
7
|
+
test loggers down regardless.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
from starlette.applications import Starlette
|
|
16
|
+
from starlette.responses import PlainTextResponse
|
|
17
|
+
from starlette.routing import Route
|
|
18
|
+
from starlette.testclient import TestClient
|
|
19
|
+
|
|
20
|
+
from seren_sinew.request_log import (
|
|
21
|
+
RequestLoggingMiddleware,
|
|
22
|
+
_default_log_filename,
|
|
23
|
+
get_logger,
|
|
24
|
+
setup_request_logger,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture(autouse=True)
|
|
29
|
+
def _clean_request_loggers():
|
|
30
|
+
"""Close + detach any '*.requests' loggers after each test so file
|
|
31
|
+
handlers release tmp_path and the next test starts clean."""
|
|
32
|
+
yield
|
|
33
|
+
for name in list(logging.root.manager.loggerDict):
|
|
34
|
+
if name.endswith(".requests"):
|
|
35
|
+
lg = logging.getLogger(name)
|
|
36
|
+
for h in list(lg.handlers):
|
|
37
|
+
h.close()
|
|
38
|
+
lg.removeHandler(h)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _build_app(service_name, log_dir, **mw_kwargs):
|
|
42
|
+
async def ok(request):
|
|
43
|
+
return PlainTextResponse("ok")
|
|
44
|
+
|
|
45
|
+
async def boom(request):
|
|
46
|
+
raise RuntimeError("kaboom")
|
|
47
|
+
|
|
48
|
+
async def slow(request):
|
|
49
|
+
await asyncio.sleep(1.05)
|
|
50
|
+
return PlainTextResponse("slow")
|
|
51
|
+
|
|
52
|
+
app = Starlette(routes=[
|
|
53
|
+
Route("/ok", ok),
|
|
54
|
+
Route("/boom", boom),
|
|
55
|
+
Route("/slow", slow),
|
|
56
|
+
])
|
|
57
|
+
app.add_middleware(
|
|
58
|
+
RequestLoggingMiddleware,
|
|
59
|
+
service_name=service_name,
|
|
60
|
+
log_dir=str(log_dir),
|
|
61
|
+
**mw_kwargs,
|
|
62
|
+
)
|
|
63
|
+
return app
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _read_log(log_dir, service_name):
|
|
67
|
+
f = log_dir / _default_log_filename(service_name)
|
|
68
|
+
return f.read_text(encoding="utf-8") if f.exists() else ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_default_filename_strips_seren_prefix():
|
|
72
|
+
assert _default_log_filename("seren-observatory") == "observatory-requests.log"
|
|
73
|
+
assert _default_log_filename("seren-lodestar") == "lodestar-requests.log"
|
|
74
|
+
assert _default_log_filename("workbench") == "workbench-requests.log"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_setup_is_idempotent(tmp_path):
|
|
78
|
+
a = setup_request_logger("test-idem", log_dir=tmp_path)
|
|
79
|
+
n_handlers = len(a.handlers)
|
|
80
|
+
b = setup_request_logger("test-idem", log_dir=tmp_path)
|
|
81
|
+
assert a is b
|
|
82
|
+
assert len(b.handlers) == n_handlers # no duplicate stacking
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_get_logger_alias(tmp_path):
|
|
86
|
+
lg = get_logger("test-alias", log_dir=tmp_path)
|
|
87
|
+
assert lg.name == "test-alias.requests"
|
|
88
|
+
assert lg.handlers # configured
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_2xx_logged_as_info(tmp_path):
|
|
92
|
+
client = TestClient(_build_app("test-ok", tmp_path))
|
|
93
|
+
assert client.get("/ok").status_code == 200
|
|
94
|
+
log = _read_log(tmp_path, "test-ok")
|
|
95
|
+
assert "GET /ok -> 200" in log
|
|
96
|
+
assert "[INFO]" in log
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_4xx_logged_as_warning(tmp_path):
|
|
100
|
+
client = TestClient(_build_app("test-404", tmp_path))
|
|
101
|
+
assert client.get("/nope").status_code == 404
|
|
102
|
+
log = _read_log(tmp_path, "test-404")
|
|
103
|
+
assert "-> 404" in log
|
|
104
|
+
assert "[WARNING]" in log
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_5xx_logged_as_error_with_traceback(tmp_path):
|
|
108
|
+
client = TestClient(_build_app("test-500", tmp_path), raise_server_exceptions=False)
|
|
109
|
+
assert client.get("/boom").status_code == 500
|
|
110
|
+
log = _read_log(tmp_path, "test-500")
|
|
111
|
+
assert "500 EXCEPTION" in log
|
|
112
|
+
assert "RuntimeError: kaboom" in log
|
|
113
|
+
assert "[ERROR]" in log
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_slow_2xx_gets_slow_warning(tmp_path):
|
|
117
|
+
client = TestClient(_build_app("test-slow", tmp_path))
|
|
118
|
+
assert client.get("/slow").status_code == 200
|
|
119
|
+
log = _read_log(tmp_path, "test-slow")
|
|
120
|
+
assert "[slow]" in log
|
|
121
|
+
assert "[WARNING]" in log
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_query_omitted_by_default(tmp_path):
|
|
125
|
+
client = TestClient(_build_app("test-q", tmp_path))
|
|
126
|
+
client.get("/ok?secret=shhh")
|
|
127
|
+
log = _read_log(tmp_path, "test-q")
|
|
128
|
+
assert "secret=shhh" not in log
|
|
129
|
+
assert "GET /ok -> 200" in log
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_query_included_when_env_set(tmp_path, monkeypatch):
|
|
133
|
+
monkeypatch.setenv("SEREN_TEST_LOG_QUERY", "1")
|
|
134
|
+
client = TestClient(_build_app("test-q2", tmp_path, env_prefix="SEREN_TEST"))
|
|
135
|
+
client.get("/ok?secret=shhh")
|
|
136
|
+
log = _read_log(tmp_path, "test-q2")
|
|
137
|
+
assert "secret=shhh" in log
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_uses_ascii_arrow_not_unicode(tmp_path):
|
|
141
|
+
# Guard the cross-platform-stderr lesson: the log line must use ASCII '->'.
|
|
142
|
+
client = TestClient(_build_app("test-ascii", tmp_path))
|
|
143
|
+
client.get("/ok")
|
|
144
|
+
log = _read_log(tmp_path, "test-ascii")
|
|
145
|
+
assert "->" in log
|
|
146
|
+
assert "\u2192" not in log
|