Skip to content

Commit b947484

Browse files
committed
Propagate Gunicorn Uvicorn worker logs
1 parent 68ceb47 commit b947484

4 files changed

Lines changed: 232 additions & 30 deletions

File tree

.basedpyright/baseline.json

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1445,6 +1445,86 @@
14451445
"lineCount": 1
14461446
}
14471447
},
1448+
{
1449+
"code": "reportAny",
1450+
"range": {
1451+
"startColumn": 21,
1452+
"endColumn": 59,
1453+
"lineCount": 1
1454+
}
1455+
},
1456+
{
1457+
"code": "reportAny",
1458+
"range": {
1459+
"startColumn": 21,
1460+
"endColumn": 59,
1461+
"lineCount": 1
1462+
}
1463+
},
1464+
{
1465+
"code": "reportAny",
1466+
"range": {
1467+
"startColumn": 21,
1468+
"endColumn": 62,
1469+
"lineCount": 1
1470+
}
1471+
},
1472+
{
1473+
"code": "reportAny",
1474+
"range": {
1475+
"startColumn": 21,
1476+
"endColumn": 62,
1477+
"lineCount": 1
1478+
}
1479+
},
1480+
{
1481+
"code": "reportAny",
1482+
"range": {
1483+
"startColumn": 4,
1484+
"endColumn": 16,
1485+
"lineCount": 1
1486+
}
1487+
},
1488+
{
1489+
"code": "reportAny",
1490+
"range": {
1491+
"startColumn": 19,
1492+
"endColumn": 32,
1493+
"lineCount": 1
1494+
}
1495+
},
1496+
{
1497+
"code": "reportAny",
1498+
"range": {
1499+
"startColumn": 14,
1500+
"endColumn": 37,
1501+
"lineCount": 1
1502+
}
1503+
},
1504+
{
1505+
"code": "reportAny",
1506+
"range": {
1507+
"startColumn": 40,
1508+
"endColumn": 61,
1509+
"lineCount": 1
1510+
}
1511+
},
1512+
{
1513+
"code": "reportAny",
1514+
"range": {
1515+
"startColumn": 4,
1516+
"endColumn": 11,
1517+
"lineCount": 1
1518+
}
1519+
},
1520+
{
1521+
"code": "reportAny",
1522+
"range": {
1523+
"startColumn": 20,
1524+
"endColumn": 33,
1525+
"lineCount": 1
1526+
}
1527+
},
14481528
{
14491529
"code": "reportAny",
14501530
"range": {

docs/logging.md

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ See [environment variable reference](environment.md).
1313
- inboard's logging configuration logic is located in [`logging_conf.py`](https://github.com/br3ndonland/inboard/blob/HEAD/inboard/logging_conf.py). By default, inboard will load the `LOGGING_CONFIG` dictionary in this module. The dictionary was named for consistency with [Uvicorn's logging configuration dictionary](https://github.com/encode/uvicorn/blob/HEAD/uvicorn/config.py).
1414
- When running Uvicorn alone, logging is configured programmatically from within the [`start.py` start script](https://github.com/br3ndonland/inboard/blob/HEAD/inboard/start.py), by passing the `LOGGING_CONFIG` dictionary to `uvicorn.run()`.
1515
- When running Gunicorn with the Uvicorn worker, the logging configuration dictionary is specified within the [`gunicorn_conf.py`](https://github.com/br3ndonland/inboard/blob/HEAD/inboard/gunicorn_conf.py) configuration file.
16+
- The Gunicorn Uvicorn worker has been updated to remove handlers from `uvicorn.error` and `uvicorn.access` and instead propagate those log records to the root logger. This avoids duplicate records and ensures the root handler applies the configured formatter and filters.
17+
- The Uvicorn worker class originally disabled propagation because it resulted in duplicate logs if enabled ([encode/uvicorn#614](https://github.com/encode/uvicorn/issues/614), [encode/uvicorn#623](https://github.com/encode/uvicorn/pull/623)). As the [docs](https://docs.python.org/3/library/logging.html#logging.Logger.propagate) on `logging.Logger.propagate` explain, "If you attach a handler to a logger _and_ one or more of its ancestors, it may emit the same record multiple times."
18+
- Instead of disabling propagation and keeping Gunicorn handlers set on the logger, another solution is to remove the Gunicorn handlers and enable propagation so the root logger can manage all logs ([br3ndonland/inboard#131](https://github.com/br3ndonland/inboard/discussions/131)).
1619

1720
## Filtering log messages
1821

@@ -128,7 +131,8 @@ If the inboard Python package is installed from PyPI, the logging configuration
128131
"()": "package.custom_logging.MyFormatterClass",
129132
}
130133

131-
# only show access logs when running Uvicorn with LOG_LEVEL=debug
134+
# only show access logs when running Uvicorn alone with LOG_LEVEL=debug
135+
# Gunicorn-managed Uvicorn workers always propagate access logs to root
132136
LOGGING_CONFIG["loggers"]["gunicorn.access"] = {"propagate": False}
133137
LOGGING_CONFIG["loggers"]["uvicorn.access"] = {
134138
"propagate": str(os.getenv("LOG_LEVEL")) == "debug"
@@ -143,7 +147,7 @@ If the inboard Python package is installed from PyPI, the logging configuration
143147

144148
## Overriding the logging config
145149

146-
Want to override inboard's entire logging config? No problem. Set up a separate `LOGGING_CONFIG` dictionary, and pass inboard the path to the module containing the dictionary. Try something like this:
150+
Want to override inboard's entire logging config? No problem. Set up a separate `LOGGING_CONFIG` dictionary, and pass inboard the path to the module containing the dictionary. Gunicorn-managed Uvicorn workers route `uvicorn.error` and `uvicorn.access` through the root logger, so configure the root handler with the formatter and output stream those records should use. Logger-specific Uvicorn handlers only apply when running Uvicorn without Gunicorn. Try something like this:
147151

148152
!!! example "Example of a complete custom logging config"
149153

@@ -161,11 +165,7 @@ Want to override inboard's entire logging config? No problem. Set up a separate
161165
"format": "%(asctime)s [%(process)d] [%(levelname)s] %(message)s",
162166
"datefmt": "[%Y-%m-%d %H:%M:%S %z]",
163167
},
164-
# Format Uvicorn loggers with Uvicorn's config directly
165-
"uvicorn.access": {
166-
"()": UVICORN_LOGGING_CONFIG["formatters"]["access"]["()"],
167-
"format": UVICORN_LOGGING_CONFIG["formatters"]["access"]["fmt"],
168-
},
168+
# Format propagated logs with Uvicorn's default formatter
169169
"uvicorn.default": {
170170
"()": UVICORN_LOGGING_CONFIG["formatters"]["default"]["()"],
171171
"format": UVICORN_LOGGING_CONFIG["formatters"]["default"]["fmt"],
@@ -190,12 +190,6 @@ Want to override inboard's entire logging config? No problem. Set up a separate
190190
"formatter": "gunicorn.access",
191191
"stream": "ext://sys.stdout",
192192
},
193-
# Add a separate handler just for uvicorn.access
194-
"uvicorn.access": {
195-
"class": "logging.StreamHandler",
196-
"formatter": "uvicorn.access",
197-
"stream": "ext://sys.stdout",
198-
},
199193
},
200194
"loggers": {
201195
"fastapi": {"propagate": True},
@@ -207,20 +201,17 @@ Want to override inboard's entire logging config? No problem. Set up a separate
207201
"level": "INFO",
208202
"propagate": False,
209203
},
210-
# Use the uvicorn.access handler, and don't propagate to root
204+
# Propagate Uvicorn logs to the configured root handler
211205
"uvicorn.access": {
212-
"handlers": ["uvicorn.access"],
213206
"level": "INFO",
214-
"propagate": False,
207+
"propagate": True,
215208
},
216-
# Use the error handler to output to stderr, and don't propagate to root
217209
"uvicorn.error": {
218-
"handlers": ["error"],
219210
"level": "INFO",
220-
"propagate": False,
211+
"propagate": True,
221212
},
222213
},
223-
# Use the uvicorn.default formatter for root
214+
# Format all propagated logs with uvicorn.default
224215
"root": {"handlers": ["default"], "level": "INFO"},
225216
}
226217

inboard/gunicorn_workers.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
from uvicorn.server import Server
4141

4242

43-
class UvicornWorker(Worker): # type: ignore[misc]
43+
class UvicornWorker(Worker):
4444
"""
4545
A worker class for Gunicorn that interfaces with an ASGI consumer callable,
4646
rather than a WSGI callable.
@@ -52,14 +52,14 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
5252
super().__init__(*args, **kwargs)
5353

5454
logger = logging.getLogger("uvicorn.error")
55-
logger.handlers = self.log.error_log.handlers
55+
logger.handlers = []
5656
logger.setLevel(self.log.error_log.level)
57-
logger.propagate = False
57+
logger.propagate = True
5858

5959
logger = logging.getLogger("uvicorn.access")
60-
logger.handlers = self.log.access_log.handlers
60+
logger.handlers = []
6161
logger.setLevel(self.log.access_log.level)
62-
logger.propagate = False
62+
logger.propagate = True
6363

6464
config_kwargs: dict[str, Any] = {
6565
"app": None,

tests/test_gunicorn_workers.py

Lines changed: 136 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,15 @@ async def app_with_lifespan_startup_failure(
8686
await send(lifespan_startup_failed_event)
8787

8888

89+
async def app_with_unhandled_exception(
90+
scope: Scope, _: ASGIReceiveCallable, send: ASGISendCallable
91+
) -> None:
92+
"""An ASGI app for testing unhandled request exceptions."""
93+
del send
94+
assert scope["type"] == "http"
95+
raise RuntimeError("Unhandled ASGI exception")
96+
97+
8998
@pytest.fixture
9099
def tls_certificate_authority() -> trustme.CA:
91100
return trustme.CA()
@@ -163,6 +172,26 @@ def worker_class(request: pytest.FixtureRequest) -> str:
163172
)
164173

165174

175+
@pytest.fixture(
176+
params=(
177+
pytest.param(gunicorn_workers_inboard.UvicornWorker, marks=pytestmarks),
178+
pytest.param(gunicorn_workers_inboard.UvicornH11Worker, marks=pytestmarks),
179+
)
180+
)
181+
def worker_class_uvicorn(request: pytest.FixtureRequest) -> str:
182+
"""Gunicorn Uvicorn worker class names to test.
183+
184+
This is a parametrized fixture. When the fixture is used in a test, the test
185+
will be automatically parametrized, running once for each fixture parameter. All
186+
tests using the fixture will be automatically marked with `pytest.mark.subprocess`.
187+
188+
https://docs.pytest.org/en/latest/how-to/fixtures.html
189+
https://docs.pytest.org/en/latest/proposals/parametrize_with_fixtures.html
190+
"""
191+
worker_class = request.param
192+
return f"{worker_class.__module__}.{worker_class.__name__}"
193+
194+
166195
@pytest.fixture(
167196
params=(
168197
pytest.param(False, id="TLS off"),
@@ -229,6 +258,74 @@ def gunicorn_process(
229258
assert process.poll() is not None
230259

231260

261+
@pytest.fixture(
262+
params=(
263+
pytest.param(False, id="TLS off"),
264+
pytest.param(True, id="TLS on"),
265+
)
266+
)
267+
def gunicorn_uvicorn_process_with_unhandled_exception(
268+
request: pytest.FixtureRequest,
269+
tls_ca_certificate_pem_path: str,
270+
tls_ca_ssl_context: SSLContext,
271+
tls_certificate_private_key_path: str,
272+
tls_certificate_server_cert_path: str,
273+
unused_tcp_port: int,
274+
worker_class_uvicorn: str,
275+
) -> Generator[Process, None, None]:
276+
"""Yield a subprocess running a Gunicorn arbiter with a Uvicorn worker.
277+
278+
An instance of `httpxyz.Client` is available on the `client` attribute.
279+
Output is saved to a temporary file and accessed with `read_output()`.
280+
"""
281+
app_module = f"{__name__}:{app_with_unhandled_exception.__name__}"
282+
bind = f"127.0.0.1:{unused_tcp_port}"
283+
use_tls: bool = request.param
284+
args = [
285+
"gunicorn",
286+
"--bind",
287+
bind,
288+
"--config",
289+
"python:inboard.gunicorn_conf",
290+
"--graceful-timeout",
291+
"1",
292+
"--log-level",
293+
"debug",
294+
"--worker-class",
295+
worker_class_uvicorn,
296+
"--workers",
297+
"1",
298+
]
299+
if use_tls is True:
300+
args_for_tls = [
301+
"--ca-certs",
302+
tls_ca_certificate_pem_path,
303+
"--certfile",
304+
tls_certificate_server_cert_path,
305+
"--keyfile",
306+
tls_certificate_private_key_path,
307+
]
308+
args.extend(args_for_tls)
309+
base_url = f"https://{bind}"
310+
verify: SSLContext | bool = tls_ca_ssl_context
311+
else:
312+
base_url = f"http://{bind}"
313+
verify = False
314+
args.append(app_module)
315+
transport = httpxyz.HTTPTransport(retries=5, verify=verify)
316+
with (
317+
httpxyz.Client(base_url=base_url, transport=transport) as client,
318+
tempfile.TemporaryFile() as output,
319+
):
320+
with Process(args, client=client, output=output) as process:
321+
time.sleep(2)
322+
assert process.poll() is None
323+
yield process
324+
process.terminate()
325+
_ = process.wait(timeout=5)
326+
assert process.poll() is not None
327+
328+
232329
@pytest.fixture
233330
def gunicorn_process_with_lifespan_startup_failure(
234331
unused_tcp_port: int,
@@ -306,11 +403,11 @@ def test_uvicorn_worker_boot_error(
306403
) -> None:
307404
"""Test Gunicorn arbiter shutdown behavior after Uvicorn worker boot errors.
308405
309-
Previously, if Uvicorn workers raised exceptions during startup,
310-
Gunicorn continued trying to boot workers ([#1066]). To avoid this,
311-
the Uvicorn worker was updated to exit with `Arbiter.WORKER_BOOT_ERROR`,
312-
but no tests were included at that time ([#1077]). This test verifies
313-
that Gunicorn shuts down appropriately after a Uvicorn worker boot error.
406+
Previously, if Uvicorn workers raised exceptions during startup, Gunicorn continued
407+
trying to boot workers ([encode/uvicorn#1066]). To avoid this, the Uvicorn worker
408+
was updated to exit with `Arbiter.WORKER_BOOT_ERROR`, but no tests were included at
409+
that time ([encode/uvicorn#1077]). This test verifies that Gunicorn shuts down
410+
appropriately after a Uvicorn worker boot error.
314411
315412
When a worker exits with `Arbiter.WORKER_BOOT_ERROR`, the Gunicorn arbiter will
316413
also terminate, so there is no need to send a separate signal to the arbiter.
@@ -324,6 +421,40 @@ def test_uvicorn_worker_boot_error(
324421
assert "Worker failed to boot" in output_text
325422

326423

424+
def test_uvicorn_worker_logging_config(
425+
gunicorn_uvicorn_process_with_unhandled_exception: Process,
426+
) -> None:
427+
"""Test that Uvicorn worker logs propagate to the root logging configuration
428+
instead of using Gunicorn handlers.
429+
430+
The Uvicorn worker class originally disabled propagation because it resulted in
431+
duplicate logs if enabled ([encode/uvicorn#614], [encode/uvicorn#623]). As the
432+
[docs](https://docs.python.org/3/library/logging.html#logging.Logger.propagate) on
433+
`logging.Logger.propagate` explain, "If you attach a handler to a logger _and_ one
434+
or more of its ancestors, it may emit the same record multiple times."
435+
436+
Instead of disabling propagation and keeping Gunicorn handlers set on the logger,
437+
another solution is to remove the Gunicorn handlers and enable propagation so the
438+
root logger can manage all logs ([br3ndonland/inboard#131]).
439+
440+
[br3ndonland/inboard#131]: https://github.com/br3ndonland/inboard/discussions/131
441+
[encode/uvicorn#614]: https://github.com/encode/uvicorn/issues/614
442+
[encode/uvicorn#623]: https://github.com/encode/uvicorn/pull/623
443+
"""
444+
response = gunicorn_uvicorn_process_with_unhandled_exception.client.get("/")
445+
output_text = gunicorn_uvicorn_process_with_unhandled_exception.read_output()
446+
exception_lines = [
447+
line
448+
for line in output_text.splitlines()
449+
if "Exception in ASGI application" in line
450+
]
451+
assert response.status_code == 500
452+
assert len(exception_lines) == 1
453+
assert "uvicorn.error" in exception_lines[0]
454+
assert output_text.count('"GET / HTTP/1.1" 500') == 1
455+
assert output_text.count("RuntimeError: Unhandled ASGI exception") == 1
456+
457+
327458
def test_worker_get_request(gunicorn_process: Process) -> None:
328459
"""Test a GET request to the Gunicorn worker's ASGI app."""
329460
response = gunicorn_process.client.get("/")

0 commit comments

Comments
 (0)