laravel-cloud-logging
Python logging for Laravel Cloud that matches a Laravel app's logs. Levels, context and exception chains show up in the Cloud dashboard the same way they do for Laravel. The package has no runtime dependencies. It supports Python 3.10 to 3.14.
from laravel_cloud_logging import configure
configure()
Call configure() once, as early as possible at startup. It:
- replaces existing handlers on the root logger and on common framework loggers (
uvicorn,gunicorn,celery,django,werkzeug,asyncio,rq.worker,py.warnings), and makes those loggers propagate to root; - captures
warnings; - logs uncaught exceptions (main thread and
threading) at CRITICAL; - silences
uvicorn.accessandgunicorn.access; - returns a
logging.config.dictConfigdict, which Gunicorn can use.
You can call it more than once. Logging never raises into your app.
configure(level=None, *, exceptions=True, access_logs=False)
level: a name ("debug","notice") or a number. Default: theLOG_LEVELenvironment variable, thenINFO. Unknown names fall back toINFO.exceptions=False: do not install the uncaught-exception hooks.access_logs=True: keep app-server access logs. They are off by default because Cloud's nginx already logs every request, with its status and timing.
pip install laravel-cloud-logging
Framework setup
wsgi_middleware and asgi_middleware are imported from laravel_cloud_logging.
| Framework | Setup |
|---|---|
| Plain script | Call configure() before the first log call. |
| Flask | Call configure() before you create the app. Then set app.wsgi_app = wsgi_middleware(app.wsgi_app). |
| FastAPI / Starlette | Call configure() and app.add_middleware(asgi_middleware). Then start the server with uvicorn.run(app, log_config=None). If you run several uvicorn workers, also call configure() in the module that defines the app. |
| Django | In settings.py, set LOGGING_CONFIG = None and call configure(). Add "laravel_cloud_logging.django.middleware" near the top of MIDDLEWARE. Under ASGI, you can wrap the app in asgi.py instead: application = asgi_middleware(get_asgi_application()). |
| Gunicorn | In gunicorn.conf.py, set logconfig_dict = configure(). Do not set accesslog. |
| Celery | Call from laravel_cloud_logging.celery import setup; setup(app). This sets worker_hijack_root_logger=False and connects configure() to the setup_logging signal with weak=False. Keyword arguments are passed to configure(). |
| RQ | Call configure() before or after the worker sets up its logging. Both orders work, because configure() also clears the handlers on rq.worker. |
laravel-cloud-queues |
Call configure() before you start the worker. The worker calls basicConfig only when root has no handlers, so it keeps yours. Note: the worker's JSON job-event lines have no level or message today, so the dashboard shows them as plain entries. |
Request IDs
The middleware reads the Cloud-Request-ID header into a contextvars.ContextVar (laravel_cloud_logging.cloud_request_id). Every record logged during the request then has context.cloud_request_id, which replaces any cloud_request_id you pass in extra=. The platform sets this header and replaces any value a client sends.
The package does not use X-Request-ID, because clients can set it and the platform passes it through. IDs longer than 128 characters are ignored.
- WSGI and Django set the variable on every request, to
Nonewhen the header is missing. That way a reused worker thread never keeps an old ID. - ASGI matches the header name in any case, handles only
httpandwebsocketscopes, and resets the variable when the request finishes.
Wire format
Each record is one compact JSON object on one line, in the Monolog shape that Laravel uses. The keys are always in this order:
| Key | Value |
|---|---|
message |
record.getMessage() |
context |
Always present ({} when empty). It holds every extra= field, plus cloud_request_id, exception and stack (stack_info). |
level |
Monolog number (see below) |
level_name |
Monolog name (see below) |
channel |
APP_ENV, then LARAVEL_CLOUD_ENV_NAME, then local |
datetime |
record.created as UTC ISO-8601 with microseconds, +00:00. For display only: the platform orders logs by the time it receives them. |
extra |
{"logger": record.name} |
Python levels map to Monolog levels by rounding down:
| Python level | level |
level_name |
|---|---|---|
| 60 and above | 600 | EMERGENCY |
| 55 | 550 | ALERT |
| 50 (CRITICAL) | 500 | CRITICAL |
| 40 (ERROR) | 400 | ERROR |
| 30 (WARNING) | 300 | WARNING |
| 25 | 250 | NOTICE |
| 20 (INFO) | 200 | INFO |
| below 20 | 100 | DEBUG |
configure() registers NOTICE (25), ALERT (55) and EMERGENCY (60) as Python level names, but only when those numbers have no name yet. The constants are exported too: logger.log(laravel_cloud_logging.ALERT, "..."). The dashboard styles all eight names. The public logs API collapses them to info, warning, error and debug.
Exceptions go in context.exception as {class, message, code, file, trace, previous}:
classis module-qualified, withoutbuiltins..codeisargs[0]when it is an int (not a bool). Otherwise it is 0.fileispath:lineof the innermost frame.traceholds up to 100path:line in funcstrings, innermost first.previousfollows__cause__, or__context__unless it is suppressed. It is recursive and safe against cycles.
The dashboard shows the whole chain.
Normalization follows Monolog's rules:
- depth is limited to 9, and each container to 1000 items, using Monolog's marker strings;
- non-finite floats become strings;
- other objects become
str(), and an object that cannot be printed becomes a marker.
Size cap: 256 KiB per line.
- First, the message and long top-level context strings are cut to 16 KiB each, with
[truncated]added. The exception trace is cut to 20 frames, andpreviousis dropped. - If the line is still too long, only
exception,cloud_request_idand atruncatednote are kept. - If it is still too long,
contextkeeps only the note, andchanneland the logger name are cut to 16 KiB.
The 256 KiB budget includes the trailing newline. Cuts count UTF-8 bytes and never split a character.
The result is always valid JSON at the right level.
Why only these seven top-level keys
The platform picks the record type from top-level keys:
source: "nginx-app"makes the line a fake access log;logger: "http.log.access.log0"makes it a Caddy access log;_cloud_eventtakes the line out of the logs;contextselects the Laravel path.
So your extra= fields always go inside context, and can never reach the top level. The platform truncates records over 1 MB, and they become plain text at info level, so the 256 KiB cap keeps a large record structured. Evidence: SE-295 and its comments.
Transport and fallback
- On Cloud (
LARAVEL_CLOUD=1), lines go toLARAVEL_CLOUD_LOG_SOCKET. When that variable is not set, they go tounix:///tmp/cloud-init.sock. Python containers do not set the variable, but the socket exists. Supported addresses areunix://path,tcp://host:portandhost:port. - Why a socket: every process in a Cloud container shares one stdout pipe. In a live test, 8 processes writing 60 KB lines to stdout corrupted 29 of 40 lines. Through the socket, all 40 lines arrived intact, because cloud-init writes one line at a time. See SE-301.
- Connection:
- one
sendallper record, under the handler lock, with a 2 s timeout; - it connects on the first record;
- it reconnects after
fork(), so Gunicorn workers never share the parent's socket; - after a connect or send failure, it waits 5 s before it tries again.
- one
- Fallback: if the socket fails, or when you are not on Cloud, the whole line goes to
sys.__stdout__in one write, followed by a flush. - The platform splits socket lines over 2 MiB. The 256 KiB cap prevents this.
Limits
- Anything printed before
configure()runs is still plain text at info level. This includes interpreter crash output and server boot lines. - The dashboard cannot show whether a line came from stdout or stderr.
- There is no redaction. Keep secrets out of messages and
extra=fields. - Not in scope: Laravel's Exceptions feature (
_cloud_event: exception), which is Laravel-only for now.
Development
uv run --python 3.14 --group test pytest -q
CI runs the tests on Python 3.10 to 3.14. The framework packages are test-only dependencies.
Live check on Laravel Cloud
-
Run
python scripts/live_check.py command <env>. It prints acpx cloud command:runcommand and a marker. The command carries the package inside--cmd, so you do not need to deploy anything. -
Run the printed command. It prints the marker and a
from/towindow.command:runoutput itself is never logged, but lines sent to the socket are. -
Run
python scripts/live_check.py verify <app> <env> <marker> <from> <to>. It checks:- every entry has type
application, with the right levels; - there is exactly one exception entry, with its chain;
- the request ID is present;
- 40 of 40 concurrent lines arrived whole;
- the 600 KB record is still JSON at warning level.
The logs API returns at most 100 rows per call, so the script reads in small windows.
- every entry has type
-
Check the dashboard Logs page by hand: the level tags and colours, and the exception chain in the details panel.
Run the check on a shared (Flex) environment and on a private one.
Releasing
Publishing uses PyPI trusted publishing (OIDC), so the repo stores no tokens. See .github/workflows/publish.yml.
- Set
versioninpyproject.toml, runuv lock, and merge tomain. - Publish to TestPyPI: run the Publish workflow manually on
main(gh workflow run publish.yml --ref main). - Publish to PyPI: create a GitHub release tagged
v<version>(gh release create v0.0.1 --generate-notes). Thepypijob waits for approval in thepypienvironment.
License
MIT
Metadata
Release files for laravel-cloud-logging 0.0.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| laravel_cloud_logging-0.0.1.tar.gz | 47.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| laravel_cloud_logging-0.0.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 60.0 kB
Release files / laravel_cloud_logging-0.0.1.tar.gz
| Download URL | laravel_cloud_logging-0.0.1.tar.gz |
|---|---|
| Size | 47.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
7103e9f8131e13384ffcab9c7c432c5fe25c1fb6f74d61570326661fd13817b8
|
|
BLAKE2b-256 checksum How to use checksums |
1ebb05c1c8205d83d64451c743cf401730a4609bc9b285d1f9aa520564b37fe7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.
Transparency logRelease files / laravel_cloud_logging-0.0.1-py3-none-any.whl
| Download URL | laravel_cloud_logging-0.0.1-py3-none-any.whl |
|---|---|
| Size | 12.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a9fb828077a17287073b9dc92c6cb9bbb983420e644febc388346fb441faea62
|
|
BLAKE2b-256 checksum How to use checksums |
b99bf4e6fcced039f2a1b16bf7e3ecf8dc4473dd09e6bca6b51bb4024a83db72
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 2, 2026.
Transparency log