Metadata-Version: 2.5
Name: laravel-cloud-logging
Version: 0.4.0
Summary: Laravel-style (Monolog JSON) logging for Python apps on Laravel Cloud
Project-URL: Homepage, https://github.com/DGarbs51/laravel-cloud-python-logging
Project-URL: Source, https://github.com/DGarbs51/laravel-cloud-python-logging
Project-URL: Issues, https://github.com/DGarbs51/laravel-cloud-python-logging/issues
Project-URL: Releases, https://github.com/DGarbs51/laravel-cloud-python-logging/releases
Author: Devon Garbalosa
License-Expression: MIT
License-File: LICENSE
Keywords: django,fastapi,flask,json,laravel,laravel-cloud,logging,monolog
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Web Environment
Classifier: Framework :: Celery
Classifier: Framework :: Django
Classifier: Framework :: FastAPI
Classifier: Framework :: Flask
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: 3.15
Classifier: Topic :: System :: Logging
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown

# laravel-cloud-logging

Make your Python app's logs look like a Laravel app's logs in the Laravel Cloud dashboard. You get real levels, structured context, request IDs and full exception chains, without plain-text noise.

No runtime dependencies. Python 3.11 to 3.15.

## Quick start

```sh
pip install laravel-cloud-logging
```

```python
import logging
from laravel_cloud_logging import configure

configure()

logging.getLogger(__name__).info('Order shipped', extra={'order_id': 42})
```

Call `configure()` once, as early as possible at startup. Then log with the standard `logging` module. Your app needs no other changes.

Now add the setup for your framework or server below.

## Framework setup

`wsgi_middleware` and `asgi_middleware` come from `laravel_cloud_logging`. The middleware adds the Cloud request ID to every log line. See [Request IDs](#request-ids).

| Framework | Setup |
|---|---|
| Plain script | Call `configure()` before the first log call. |
| Flask | Call `configure()` before you create the app. Then add `app.wsgi_app = wsgi_middleware(app.wsgi_app)`. |
| FastAPI / Starlette | Call `configure()` and `app.add_middleware(asgi_middleware)`. Start with `uvicorn.run(app, log_config=None)`. With several 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`. |
| Celery | `from laravel_cloud_logging.celery import setup; setup(app)`. Keyword arguments go to `configure()`. |
| RQ | Call `configure()`. Before or after the worker starts, both work. |

Then start your server with the command for it below. The flags in each command keep the server's own lines in JSON or turn them off, so copy them all. `...` stands for your other options, such as the bind address.

| Server | Setup | Start command |
|---|---|---|
| Gunicorn | In `gunicorn.conf.py`, set `logconfig_dict = configure()`. Do not set `accesslog`. | `gunicorn -c gunicorn.conf.py ... app:app` |
| Uvicorn | Call `configure()` in the app module. Build `logging.json`: see [Log config file](#log-config-file). | `uvicorn --log-config logging.json ... app:app` |
| Hypercorn | Call `configure()` in the app module. Build `logging.json`: see [Log config file](#log-config-file). | `hypercorn --log-config json:logging.json ... app:app` |
| Granian | Call `configure()` in the app module. Build `logging.json`: see [Log config file](#log-config-file). | `granian --log-config logging.json ... app:app` |
| Waitress | Call `configure()` in the app module. | `waitress-serve ... app:app` |
| Daphne | Call `configure()` in `asgi.py`. | `daphne -v 0 ... project.asgi:application` |
| uWSGI / pyuwsgi | Call `configure()` in the app module. | `uwsgi --disable-logging --die-on-term ... --module app:app` |

- Hypercorn needs the `json:` prefix. Without it, Hypercorn reads the file as an INI config.
- Daphne writes its access log straight to stdout, outside Python's `logging`, so the library can't turn it off. `-v 0` does.
- uWSGI: `--disable-logging` turns off its plain-text access log. `--die-on-term` makes it stop on `SIGTERM`; uWSGI 2.0 reloads instead.
- uWSGI closes the connection without a response when an exception escapes your WSGI app, so Cloud's nginx answers 502 instead of 500. The exception is still logged. Use a framework that turns exceptions into a 500 response (Flask and Django do), or catch them in your app.
- Hypercorn can also take the config in Python: set `logconfig_dict = configure()` in `hypercorn.conf.py` and start with `hypercorn -c file:hypercorn.conf.py ...`.

### Log config file

Uvicorn, Hypercorn and Granian can log from their main process before `configure()` runs, and with several workers it never runs there. Those boot, worker and shutdown lines would be plain text. All three servers read a JSON logging config file in the main process. On Laravel Cloud, add this to your environment's build commands, after your dependencies install:

```sh
laravel-cloud-logging-config logging.json
```

The file is then part of the image every replica starts from. If the command isn't on your `PATH`, run `python -m laravel_cloud_logging.config logging.json` instead. Then start the server with the file:

```sh
uvicorn --workers 4 --log-config logging.json ... app:app
hypercorn --workers 4 --log-config json:logging.json ... app:app
granian --interface asgi --workers 4 --log-config logging.json ... app:app
```

The file only sets up handlers. Keep the `configure()` call in your app module too: it also captures warnings and uncaught exceptions in each worker. The command reads `LOG_LEVEL` and `LOG_FORMAT` when it runs. Changing them on Cloud needs a new deployment, which rebuilds the file. The file always uses JSON lines unless `LOG_FORMAT=line`, even when you run the command in a terminal. The command only writes the file: it does not change logging in the process that runs it. Without a path, it prints the config to stdout. It is the dict that `configure()` returns with default arguments.

## Options

```python
configure(level=None, *, exceptions=True, access_logs=False)
```

- `level`: a name (`"debug"`, `"notice"`) or a number. Default: the `LOG_LEVEL` environment variable, then `INFO`. An unknown name also falls back to `INFO`.
- `exceptions=False`: do not log uncaught exceptions.
- `access_logs=True`: keep your server's access logs. They are off by default, because Cloud already logs every request.

### Exiting with `os._exit`

Log lines are written by a background thread. A normal exit writes the queued lines first, but `os._exit()` skips that step. Call `flush()` before it:

```python
import os

from laravel_cloud_logging import flush

flush(0.5)
os._exit(1)
```

`flush(timeout=1.0)` waits until the lines queued before the call are written, or until the timeout passes. It returns `True` if every line was written. It takes no locks, so you can call it in a signal handler. `multiprocessing` children also end with `os._exit`: call `flush()` at the end of the target function. The Celery integration already does this for prefork pool children.

## Local development

Off Cloud, when stdout is a terminal, `configure()` prints readable lines instead of JSON:

```
14:19:23 INFO    Order shipped  order_id=42
14:19:24 ERROR   Request failed
    RuntimeError: payment declined
      at /app/billing.py:88
    Caused by TimeoutError: timed out
      at /app/gateway.py:31
```

Piped output, CI and Cloud keep the JSON lines. Set `LOG_FORMAT=json` or `LOG_FORMAT=line` to choose. On Cloud (`LARAVEL_CLOUD=1`), lines are always JSON. Colors are on only in a terminal and only when `NO_COLOR` is not set. Control characters in messages and values print escaped.

To read JSON lines, filter them, or run your server through the viewer, use `pretty`. It works like Laravel's `php artisan pail`:

```sh
python -m laravel_cloud_logging.pretty -- uvicorn app:app --reload
python -m laravel_cloud_logging.pretty --level warning -- celery -A tasks worker
python -m laravel_cloud_logging.pretty --request 9f1c... < saved.log
```

- `--level LEVEL`: show this level and above.
- `--request ID`: show only records with this `cloud_request_id`.
- `--grep TEXT`: show only lines that contain this text. Case does not matter.

After `--`, `pretty` runs the command, reads its stdout and stderr, and exits with the command's exit code. It forwards `SIGTERM` to the command. Lines that are not records, such as server boot lines and `print()` output, pass through with control characters escaped, so log text cannot drive your terminal. `--level` and `--request` hide them. Without filters, a prompt such as `(Pdb)` shows before its newline, so `breakpoint()` works. A line longer than 256 KiB shows in pieces. Without `--`, `pretty` reads stdin.

## What `configure()` does

- Sends every log record to Cloud as one JSON line, in the Monolog format that Laravel uses. In a local terminal, it prints readable lines instead. See [Local development](#local-development).
- Takes over the root logger and common framework loggers (`uvicorn`, `gunicorn`, `django`, `celery` and others).
- Captures `warnings` and uncaught exceptions, including exceptions in threads.
- Turns off server access logs. Cloud's nginx already logs each request.
- Returns a `logging.config.dictConfig` dict for servers that accept one. The dict is plain JSON, so it also works as a [log config file](#log-config-file).

It is safe to call more than once. The formatter and handler never raise an error into your app. Python's `logging` itself still checks your arguments: see [Limits](#limits).

## Request IDs

The middleware reads the `Cloud-Request-ID` header. Every record logged during that request gets `context.cloud_request_id`, so you can find all the logs for one request. The ID is also available as the `laravel_cloud_logging.cloud_request_id` context variable.

With `wsgi_middleware` and the Django middleware, the ID stays set on the worker thread until the next request. Records logged on that thread between requests keep the last request's ID. `asgi_middleware` clears the ID when the request ends.

Cloud sets this header and replaces any value a client sends. The package ignores `X-Request-ID`, because clients control it.

Only trust the ID on Cloud. Off Cloud, nothing strips the header, so any client can set it. The middleware accepts up to 128 letters, digits, `.`, `_`, `:` and `-`. Other values leave the ID unset.

## Log levels

Python levels map to Laravel levels by rounding down:

| Python | Laravel |
|---|---|
| 60 and above | EMERGENCY |
| 55 | ALERT |
| 50 (CRITICAL) | CRITICAL |
| 40 (ERROR) | ERROR |
| 30 (WARNING) | WARNING |
| 25 | NOTICE |
| 20 (INFO) | INFO |
| below 20 | DEBUG |

The `NOTICE`, `ALERT` and `EMERGENCY` constants are exported: `logger.log(laravel_cloud_logging.ALERT, "...")`.

## Exceptions

Log with `logger.exception(...)` or `exc_info=True`. The dashboard shows the exception with its class, message, file, trace and the full `previous` chain (`raise ... from ...`).

An exception that escapes your app is logged once by the middleware, as `Uncaught exception in GET /path` at `ERROR`, with `context.cloud_request_id`. That includes exceptions raised while a response streams. The server still answers as before, usually with a 500. Every server also logs the exception itself, often without the request ID, and Granian logs it as plain text. The handler drops those second copies. Without the middleware, you get the server's own record.

Frameworks such as Django, Flask and FastAPI catch most exceptions themselves and log them through `logging`, so the middleware only sees what they let through.

## Limits

- Output from before `configure()` runs is plain text. This includes interpreter crashes and server boot lines, unless the server reads a [log config file](#log-config-file).
- uWSGI's own boot, worker and shutdown lines are plain text. uWSGI writes them from C, outside Python's `logging`. Its log encoders can wrap them in JSON, but not cleanly: a few setup lines stay plain, levels come from matching the text, and `datetime` has no fraction of a second. See [#33](https://github.com/DGarbs51/laravel-cloud-python-logging/issues/33) for a tested recipe.
- If you add `--logger` or `--log-master` to uWSGI, also set `--log-master-bufsize 1048576`. The default 8 KB buffer silently drops longer lines written to stdout or stderr, such as JSON records with exception traces.
- There is no redaction. Keep secrets out of messages and `extra=` fields.
- Python's `logging` raises `KeyError` for `extra=` keys that are `LogRecord` attributes, such as `name`, `message` or `module`. Nest them instead: `extra={'order': {'name': name}}`.
- An `extra=` field named `color_message` is dropped. Uvicorn uses it for an ANSI-colored copy of the message.
- Laravel's Exceptions feature is not supported yet.

## How it works

Each record is one JSON line with the keys `message`, `context`, `level`, `level_name`, `channel`, `datetime` and `extra`. Your `extra=` fields always go inside `context`. The package never adds other top-level keys, because Cloud uses top-level keys to choose how to parse a line.

- **Transport:** On Cloud (`LARAVEL_CLOUD=1`), lines go to the log socket (`LARAVEL_CLOUD_LOG_SOCKET`, default `unix:///tmp/cloud-init.sock`). Every process in a container shares one stdout pipe, so large lines from several processes can mix together. The socket keeps each line whole. If the socket fails, or you are not on Cloud, lines go to stdout. The format is chosen from `LARAVEL_CLOUD`, not from the transport, so the stdout fallback on Cloud is still JSON.
- **Background writer:** The handler formats each record on the thread that logs it, then queues the line. A background thread writes queued lines, so a slow socket or stdout never blocks your event loop. Each process has its own writer, so lines from one process stay in order. A child process starts its own writer on its first log line after a fork. It never reuses the parent's queue, and Python's `logging` resets handler locks in the child, so a fork during another thread's log call can't hang the child. The queue holds `LARAVEL_CLOUD_LOG_QUEUE` lines (default 10,000). When it is full, new lines are dropped. When the queue empties again, one `WARNING` record says how many were dropped, with the count in `context.dropped`. On exit, the handler waits up to 1 second for queued lines. Set `LARAVEL_CLOUD_LOG_SYNC=1` to write each line on the thread that logs it instead.
- **Size cap:** Each line is at most 256 KiB. Long messages and traces are cut first, then extra context. Cloud turns records over 1 MB into plain text, so this cap keeps large records structured.
- **Normalization:** Like Monolog, context stops at 9 levels deep and 1,000 items per container. Each record also has a budget of 10,000 values and about 1M characters, so shared or cyclic references cannot fan out. Values past the budget become `Over normalization budget, aborting normalization`. `cloud_request_id`, `exception` and `stack` sit outside the budget, so only the size cap can drop them. Exception messages built from containers or bytes use a bounded repr: 30 items per container, 3 levels, 1,000 characters per value and 256K characters in total. The formatter cannot bound memory used by your own `__str__` methods or `%`-format arguments.
- **Channel:** `APP_ENV`, then `LARAVEL_CLOUD_ENV_NAME`, then `local`.

## Development

```sh
uv run pytest -q
uv run ruff check . && uv run ruff format --check .
uv run ty check && uv run mypy && uv run pyright
uv run coverage run -m pytest -q && uv run coverage combine && uv run coverage report
```

CI runs the tests on Python 3.11 to 3.15 and requires 100% line and branch coverage across all versions combined. One local run can show version-specific branches as missed.

### Live check on Laravel Cloud

1. Run `python scripts/live_check.py command <env>`. It prints a `cpx cloud command:run` command and a marker. You do not need to deploy anything.
2. Run the printed command. It prints the marker and a `from`/`to` time window.
3. Run `python scripts/live_check.py verify <app> <env> <marker> <from> <to>`. It checks levels, the exception, the request ID, concurrent writes and the size cap.
4. On the dashboard Logs page, check the level colors and the exception chain. The logs API does not return the `previous` chain, so check it in the dashboard.

Run the check on a shared (Flex) environment and on a private one.

### Releasing

Publishing uses PyPI trusted publishing. See `.github/workflows/publish.yml`.

1. Run `uv version --bump patch` (or `minor`/`major`) and merge to `main`.
2. TestPyPI: `gh workflow run publish.yml --ref main`.
3. PyPI: `gh release create v<version> --generate-notes`.

## License

MIT
