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.
@@ -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,6 @@
1
+ starlette>=0.37
2
+
3
+ [dev]
4
+ pytest>=8
5
+ pytest-asyncio>=0.23
6
+ httpx>=0.27
@@ -0,0 +1,11 @@
1
+ {
2
+ "files": [
3
+ "README.md",
4
+ "SerenSinew.pyproj",
5
+ "pyproject.toml",
6
+ ".gitignore",
7
+ "tests/test_request_log.py",
8
+ "seren_sinew/__init__.py",
9
+ "seren_sinew/request_log.py"
10
+ ]
11
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "1.0.0",
3
+ "distance": 0,
4
+ "node": "g5549343f4b4075b76e94ad43208078f283db90ef",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-06-25"
8
+ }
@@ -0,0 +1 @@
1
+ seren_sinew
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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