laravel-cloud-logging 0.0.1__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.
- laravel_cloud_logging-0.0.1/.github/workflows/ci.yml +57 -0
- laravel_cloud_logging-0.0.1/.github/workflows/publish.yml +78 -0
- laravel_cloud_logging-0.0.1/.gitignore +7 -0
- laravel_cloud_logging-0.0.1/LICENSE +21 -0
- laravel_cloud_logging-0.0.1/PKG-INFO +199 -0
- laravel_cloud_logging-0.0.1/README.md +176 -0
- laravel_cloud_logging-0.0.1/pyproject.toml +37 -0
- laravel_cloud_logging-0.0.1/scripts/live_check.py +141 -0
- laravel_cloud_logging-0.0.1/src/laravel_cloud_logging/__init__.py +302 -0
- laravel_cloud_logging-0.0.1/src/laravel_cloud_logging/celery.py +12 -0
- laravel_cloud_logging-0.0.1/src/laravel_cloud_logging/django.py +16 -0
- laravel_cloud_logging-0.0.1/tests/conftest.py +23 -0
- laravel_cloud_logging-0.0.1/tests/helpers.py +84 -0
- laravel_cloud_logging-0.0.1/tests/test_core.py +476 -0
- laravel_cloud_logging-0.0.1/tests/test_integrations.py +143 -0
- laravel_cloud_logging-0.0.1/uv.lock +725 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
concurrency:
|
|
12
|
+
group: ci-${{ github.workflow }}-${{ github.ref }}
|
|
13
|
+
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
|
14
|
+
|
|
15
|
+
env:
|
|
16
|
+
UV_LOCKED: "1"
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
test:
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
timeout-minutes: 10
|
|
22
|
+
strategy:
|
|
23
|
+
fail-fast: false
|
|
24
|
+
matrix:
|
|
25
|
+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
|
26
|
+
steps:
|
|
27
|
+
- uses: actions/checkout@v7
|
|
28
|
+
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
29
|
+
with:
|
|
30
|
+
python-version: ${{ matrix.python-version }}
|
|
31
|
+
- run: uv run --group test pytest -q
|
|
32
|
+
|
|
33
|
+
packaging:
|
|
34
|
+
runs-on: ubuntu-latest
|
|
35
|
+
timeout-minutes: 10
|
|
36
|
+
steps:
|
|
37
|
+
- uses: actions/checkout@v7
|
|
38
|
+
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
39
|
+
with:
|
|
40
|
+
python-version: "3.13"
|
|
41
|
+
- run: uv build
|
|
42
|
+
- run: uvx twine check --strict dist/*
|
|
43
|
+
- name: Wheel declares no runtime dependencies
|
|
44
|
+
run: unzip -p dist/*.whl '*/METADATA' | (! grep -q '^Requires-Dist')
|
|
45
|
+
|
|
46
|
+
ci:
|
|
47
|
+
if: always()
|
|
48
|
+
needs: [test, packaging]
|
|
49
|
+
runs-on: ubuntu-latest
|
|
50
|
+
timeout-minutes: 5
|
|
51
|
+
steps:
|
|
52
|
+
- name: Require all gates to pass
|
|
53
|
+
run: |
|
|
54
|
+
if [ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" = "true" ]; then
|
|
55
|
+
echo "One or more required jobs failed." >&2
|
|
56
|
+
exit 1
|
|
57
|
+
fi
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
# Manual run publishes to TestPyPI; a published GitHub release publishes to PyPI.
|
|
4
|
+
# Both use trusted publishing (OIDC), so no API tokens are stored in the repo.
|
|
5
|
+
on:
|
|
6
|
+
release:
|
|
7
|
+
types: [published]
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
|
|
13
|
+
env:
|
|
14
|
+
UV_LOCKED: "1"
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
build:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
timeout-minutes: 10
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v7
|
|
22
|
+
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
|
23
|
+
with:
|
|
24
|
+
python-version: "3.13"
|
|
25
|
+
- name: Check release tag matches project version
|
|
26
|
+
if: github.event_name == 'release'
|
|
27
|
+
env:
|
|
28
|
+
TAG: ${{ github.event.release.tag_name }}
|
|
29
|
+
run: |
|
|
30
|
+
set -euo pipefail
|
|
31
|
+
version="$(uv version --short)"
|
|
32
|
+
if [ "$TAG" != "v$version" ]; then
|
|
33
|
+
echo "::error::Release tag $TAG does not match pyproject version v$version"
|
|
34
|
+
exit 1
|
|
35
|
+
fi
|
|
36
|
+
- run: uv build
|
|
37
|
+
- run: uvx twine check --strict dist/*
|
|
38
|
+
- uses: actions/upload-artifact@v7
|
|
39
|
+
with:
|
|
40
|
+
name: dist
|
|
41
|
+
path: dist/
|
|
42
|
+
if-no-files-found: error
|
|
43
|
+
|
|
44
|
+
testpypi:
|
|
45
|
+
if: github.event_name == 'workflow_dispatch'
|
|
46
|
+
needs: build
|
|
47
|
+
runs-on: ubuntu-latest
|
|
48
|
+
timeout-minutes: 10
|
|
49
|
+
environment:
|
|
50
|
+
name: testpypi
|
|
51
|
+
url: https://test.pypi.org/p/laravel-cloud-logging
|
|
52
|
+
permissions:
|
|
53
|
+
id-token: write
|
|
54
|
+
steps:
|
|
55
|
+
- uses: actions/download-artifact@v8
|
|
56
|
+
with:
|
|
57
|
+
name: dist
|
|
58
|
+
path: dist/
|
|
59
|
+
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
|
60
|
+
with:
|
|
61
|
+
repository-url: https://test.pypi.org/legacy/
|
|
62
|
+
|
|
63
|
+
pypi:
|
|
64
|
+
if: github.event_name == 'release'
|
|
65
|
+
needs: build
|
|
66
|
+
runs-on: ubuntu-latest
|
|
67
|
+
timeout-minutes: 10
|
|
68
|
+
environment:
|
|
69
|
+
name: pypi
|
|
70
|
+
url: https://pypi.org/p/laravel-cloud-logging
|
|
71
|
+
permissions:
|
|
72
|
+
id-token: write
|
|
73
|
+
steps:
|
|
74
|
+
- uses: actions/download-artifact@v8
|
|
75
|
+
with:
|
|
76
|
+
name: dist
|
|
77
|
+
path: dist/
|
|
78
|
+
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Laravel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: laravel-cloud-logging
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Laravel-style (Monolog JSON) logging for Python apps on Laravel Cloud
|
|
5
|
+
Project-URL: Homepage, https://github.com/DGarbs51/laravel-cloud-python-logging
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: django,fastapi,flask,json,laravel,laravel-cloud,logging,monolog
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: MacOS
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: System :: Logging
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# laravel-cloud-logging
|
|
25
|
+
|
|
26
|
+
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.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from laravel_cloud_logging import configure
|
|
30
|
+
|
|
31
|
+
configure()
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Call `configure()` once, as early as possible at startup. It:
|
|
35
|
+
|
|
36
|
+
- 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;
|
|
37
|
+
- captures `warnings`;
|
|
38
|
+
- logs uncaught exceptions (main thread and `threading`) at CRITICAL;
|
|
39
|
+
- silences `uvicorn.access` and `gunicorn.access`;
|
|
40
|
+
- returns a `logging.config.dictConfig` dict, which Gunicorn can use.
|
|
41
|
+
|
|
42
|
+
You can call it more than once. Logging never raises into your app.
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
configure(level=None, *, exceptions=True, access_logs=False)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- `level`: a name (`"debug"`, `"notice"`) or a number. Default: the `LOG_LEVEL` environment variable, then `INFO`. Unknown names fall back to `INFO`.
|
|
49
|
+
- `exceptions=False`: do not install the uncaught-exception hooks.
|
|
50
|
+
- `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.
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
pip install laravel-cloud-logging
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Framework setup
|
|
57
|
+
|
|
58
|
+
`wsgi_middleware` and `asgi_middleware` are imported from `laravel_cloud_logging`.
|
|
59
|
+
|
|
60
|
+
| Framework | Setup |
|
|
61
|
+
|---|---|
|
|
62
|
+
| Plain script | Call `configure()` before the first log call. |
|
|
63
|
+
| Flask | Call `configure()` before you create the app. Then set `app.wsgi_app = wsgi_middleware(app.wsgi_app)`. |
|
|
64
|
+
| 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. |
|
|
65
|
+
| 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())`. |
|
|
66
|
+
| Gunicorn | In `gunicorn.conf.py`, set `logconfig_dict = configure()`. Do not set `accesslog`. |
|
|
67
|
+
| 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()`. |
|
|
68
|
+
| RQ | Call `configure()` before or after the worker sets up its logging. Both orders work, because `configure()` also clears the handlers on `rq.worker`. |
|
|
69
|
+
| `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. |
|
|
70
|
+
|
|
71
|
+
## Request IDs
|
|
72
|
+
|
|
73
|
+
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.
|
|
74
|
+
|
|
75
|
+
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.
|
|
76
|
+
|
|
77
|
+
- WSGI and Django set the variable on every request, to `None` when the header is missing. That way a reused worker thread never keeps an old ID.
|
|
78
|
+
- ASGI matches the header name in any case, handles only `http` and `websocket` scopes, and resets the variable when the request finishes.
|
|
79
|
+
|
|
80
|
+
## Wire format
|
|
81
|
+
|
|
82
|
+
Each record is one compact JSON object on one line, in the Monolog shape that Laravel uses. The keys are always in this order:
|
|
83
|
+
|
|
84
|
+
| Key | Value |
|
|
85
|
+
|---|---|
|
|
86
|
+
| `message` | `record.getMessage()` |
|
|
87
|
+
| `context` | Always present (`{}` when empty). It holds every `extra=` field, plus `cloud_request_id`, `exception` and `stack` (`stack_info`). |
|
|
88
|
+
| `level` | Monolog number (see below) |
|
|
89
|
+
| `level_name` | Monolog name (see below) |
|
|
90
|
+
| `channel` | `APP_ENV`, then `LARAVEL_CLOUD_ENV_NAME`, then `local` |
|
|
91
|
+
| `datetime` | `record.created` as UTC ISO-8601 with microseconds, `+00:00`. For display only: the platform orders logs by the time it receives them. |
|
|
92
|
+
| `extra` | `{"logger": record.name}` |
|
|
93
|
+
|
|
94
|
+
Python levels map to Monolog levels by rounding down:
|
|
95
|
+
|
|
96
|
+
| Python level | `level` | `level_name` |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| 60 and above | 600 | EMERGENCY |
|
|
99
|
+
| 55 | 550 | ALERT |
|
|
100
|
+
| 50 (CRITICAL) | 500 | CRITICAL |
|
|
101
|
+
| 40 (ERROR) | 400 | ERROR |
|
|
102
|
+
| 30 (WARNING) | 300 | WARNING |
|
|
103
|
+
| 25 | 250 | NOTICE |
|
|
104
|
+
| 20 (INFO) | 200 | INFO |
|
|
105
|
+
| below 20 | 100 | DEBUG |
|
|
106
|
+
|
|
107
|
+
`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.
|
|
108
|
+
|
|
109
|
+
**Exceptions** go in `context.exception` as `{class, message, code, file, trace, previous}`:
|
|
110
|
+
|
|
111
|
+
- `class` is module-qualified, without `builtins.`.
|
|
112
|
+
- `code` is `args[0]` when it is an int (not a bool). Otherwise it is 0.
|
|
113
|
+
- `file` is `path:line` of the innermost frame.
|
|
114
|
+
- `trace` holds up to 100 `path:line in func` strings, innermost first.
|
|
115
|
+
- `previous` follows `__cause__`, or `__context__` unless it is suppressed. It is recursive and safe against cycles.
|
|
116
|
+
|
|
117
|
+
The dashboard shows the whole chain.
|
|
118
|
+
|
|
119
|
+
**Normalization** follows Monolog's rules:
|
|
120
|
+
|
|
121
|
+
- depth is limited to 9, and each container to 1000 items, using Monolog's marker strings;
|
|
122
|
+
- non-finite floats become strings;
|
|
123
|
+
- other objects become `str()`, and an object that cannot be printed becomes a marker.
|
|
124
|
+
|
|
125
|
+
**Size cap: 256 KiB per line.**
|
|
126
|
+
|
|
127
|
+
1. 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, and `previous` is dropped.
|
|
128
|
+
2. If the line is still too long, only `exception`, `cloud_request_id` and a `truncated` note are kept.
|
|
129
|
+
3. If it is still too long, `context` keeps only the note, and `channel` and the logger name are cut to 16 KiB.
|
|
130
|
+
|
|
131
|
+
The 256 KiB budget includes the trailing newline. Cuts count UTF-8 bytes and never split a character.
|
|
132
|
+
|
|
133
|
+
The result is always valid JSON at the right level.
|
|
134
|
+
|
|
135
|
+
### Why only these seven top-level keys
|
|
136
|
+
|
|
137
|
+
The platform picks the record type from top-level keys:
|
|
138
|
+
|
|
139
|
+
- `source: "nginx-app"` makes the line a fake access log;
|
|
140
|
+
- `logger: "http.log.access.log0"` makes it a Caddy access log;
|
|
141
|
+
- `_cloud_event` takes the line out of the logs;
|
|
142
|
+
- `context` selects the Laravel path.
|
|
143
|
+
|
|
144
|
+
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](https://linear.app/laravel/issue/SE-295) and its comments.
|
|
145
|
+
|
|
146
|
+
## Transport and fallback
|
|
147
|
+
|
|
148
|
+
- **On Cloud** (`LARAVEL_CLOUD=1`), lines go to `LARAVEL_CLOUD_LOG_SOCKET`. When that variable is not set, they go to `unix:///tmp/cloud-init.sock`. Python containers do not set the variable, but the socket exists. Supported addresses are `unix://path`, `tcp://host:port` and `host:port`.
|
|
149
|
+
- **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](https://linear.app/laravel/issue/SE-301).
|
|
150
|
+
- **Connection:**
|
|
151
|
+
- one `sendall` per record, under the handler lock, with a 2 s timeout;
|
|
152
|
+
- it connects on the first record;
|
|
153
|
+
- it reconnects after `fork()`, so Gunicorn workers never share the parent's socket;
|
|
154
|
+
- after a connect or send failure, it waits 5 s before it tries again.
|
|
155
|
+
- **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.
|
|
156
|
+
- The platform splits socket lines over 2 MiB. The 256 KiB cap prevents this.
|
|
157
|
+
|
|
158
|
+
## Limits
|
|
159
|
+
|
|
160
|
+
- Anything printed before `configure()` runs is still plain text at info level. This includes interpreter crash output and server boot lines.
|
|
161
|
+
- The dashboard cannot show whether a line came from stdout or stderr.
|
|
162
|
+
- There is no redaction. Keep secrets out of messages and `extra=` fields.
|
|
163
|
+
- Not in scope: Laravel's Exceptions feature (`_cloud_event: exception`), which is Laravel-only for now.
|
|
164
|
+
|
|
165
|
+
## Development
|
|
166
|
+
|
|
167
|
+
```sh
|
|
168
|
+
uv run --python 3.14 --group test pytest -q
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
CI runs the tests on Python 3.10 to 3.14. The framework packages are test-only dependencies.
|
|
172
|
+
|
|
173
|
+
### Live check on Laravel Cloud
|
|
174
|
+
|
|
175
|
+
1. Run `python scripts/live_check.py command <env>`. It prints a `cpx cloud command:run` command and a marker. The command carries the package inside `--cmd`, so you do not need to deploy anything.
|
|
176
|
+
2. Run the printed command. It prints the marker and a `from`/`to` window. `command:run` output itself is never logged, but lines sent to the socket are.
|
|
177
|
+
3. Run `python scripts/live_check.py verify <app> <env> <marker> <from> <to>`. It checks:
|
|
178
|
+
- every entry has type `application`, with the right levels;
|
|
179
|
+
- there is exactly one exception entry, with its chain;
|
|
180
|
+
- the request ID is present;
|
|
181
|
+
- 40 of 40 concurrent lines arrived whole;
|
|
182
|
+
- the 600 KB record is still JSON at warning level.
|
|
183
|
+
|
|
184
|
+
The logs API returns at most 100 rows per call, so the script reads in small windows.
|
|
185
|
+
4. Check the dashboard Logs page by hand: the level tags and colours, and the exception chain in the details panel.
|
|
186
|
+
|
|
187
|
+
Run the check on a shared (Flex) environment and on a private one.
|
|
188
|
+
|
|
189
|
+
### Releasing
|
|
190
|
+
|
|
191
|
+
Publishing uses PyPI trusted publishing (OIDC), so the repo stores no tokens. See `.github/workflows/publish.yml`.
|
|
192
|
+
|
|
193
|
+
1. Set `version` in `pyproject.toml`, run `uv lock`, and merge to `main`.
|
|
194
|
+
2. Publish to TestPyPI: run the **Publish** workflow manually on `main` (`gh workflow run publish.yml --ref main`).
|
|
195
|
+
3. Publish to PyPI: create a GitHub release tagged `v<version>` (`gh release create v0.0.1 --generate-notes`). The `pypi` job waits for approval in the `pypi` environment.
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# laravel-cloud-logging
|
|
2
|
+
|
|
3
|
+
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.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
from laravel_cloud_logging import configure
|
|
7
|
+
|
|
8
|
+
configure()
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Call `configure()` once, as early as possible at startup. It:
|
|
12
|
+
|
|
13
|
+
- 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;
|
|
14
|
+
- captures `warnings`;
|
|
15
|
+
- logs uncaught exceptions (main thread and `threading`) at CRITICAL;
|
|
16
|
+
- silences `uvicorn.access` and `gunicorn.access`;
|
|
17
|
+
- returns a `logging.config.dictConfig` dict, which Gunicorn can use.
|
|
18
|
+
|
|
19
|
+
You can call it more than once. Logging never raises into your app.
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
configure(level=None, *, exceptions=True, access_logs=False)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- `level`: a name (`"debug"`, `"notice"`) or a number. Default: the `LOG_LEVEL` environment variable, then `INFO`. Unknown names fall back to `INFO`.
|
|
26
|
+
- `exceptions=False`: do not install the uncaught-exception hooks.
|
|
27
|
+
- `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.
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
pip install laravel-cloud-logging
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Framework setup
|
|
34
|
+
|
|
35
|
+
`wsgi_middleware` and `asgi_middleware` are imported from `laravel_cloud_logging`.
|
|
36
|
+
|
|
37
|
+
| Framework | Setup |
|
|
38
|
+
|---|---|
|
|
39
|
+
| Plain script | Call `configure()` before the first log call. |
|
|
40
|
+
| Flask | Call `configure()` before you create the app. Then set `app.wsgi_app = wsgi_middleware(app.wsgi_app)`. |
|
|
41
|
+
| 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. |
|
|
42
|
+
| 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())`. |
|
|
43
|
+
| Gunicorn | In `gunicorn.conf.py`, set `logconfig_dict = configure()`. Do not set `accesslog`. |
|
|
44
|
+
| 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()`. |
|
|
45
|
+
| RQ | Call `configure()` before or after the worker sets up its logging. Both orders work, because `configure()` also clears the handlers on `rq.worker`. |
|
|
46
|
+
| `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. |
|
|
47
|
+
|
|
48
|
+
## Request IDs
|
|
49
|
+
|
|
50
|
+
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.
|
|
51
|
+
|
|
52
|
+
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.
|
|
53
|
+
|
|
54
|
+
- WSGI and Django set the variable on every request, to `None` when the header is missing. That way a reused worker thread never keeps an old ID.
|
|
55
|
+
- ASGI matches the header name in any case, handles only `http` and `websocket` scopes, and resets the variable when the request finishes.
|
|
56
|
+
|
|
57
|
+
## Wire format
|
|
58
|
+
|
|
59
|
+
Each record is one compact JSON object on one line, in the Monolog shape that Laravel uses. The keys are always in this order:
|
|
60
|
+
|
|
61
|
+
| Key | Value |
|
|
62
|
+
|---|---|
|
|
63
|
+
| `message` | `record.getMessage()` |
|
|
64
|
+
| `context` | Always present (`{}` when empty). It holds every `extra=` field, plus `cloud_request_id`, `exception` and `stack` (`stack_info`). |
|
|
65
|
+
| `level` | Monolog number (see below) |
|
|
66
|
+
| `level_name` | Monolog name (see below) |
|
|
67
|
+
| `channel` | `APP_ENV`, then `LARAVEL_CLOUD_ENV_NAME`, then `local` |
|
|
68
|
+
| `datetime` | `record.created` as UTC ISO-8601 with microseconds, `+00:00`. For display only: the platform orders logs by the time it receives them. |
|
|
69
|
+
| `extra` | `{"logger": record.name}` |
|
|
70
|
+
|
|
71
|
+
Python levels map to Monolog levels by rounding down:
|
|
72
|
+
|
|
73
|
+
| Python level | `level` | `level_name` |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| 60 and above | 600 | EMERGENCY |
|
|
76
|
+
| 55 | 550 | ALERT |
|
|
77
|
+
| 50 (CRITICAL) | 500 | CRITICAL |
|
|
78
|
+
| 40 (ERROR) | 400 | ERROR |
|
|
79
|
+
| 30 (WARNING) | 300 | WARNING |
|
|
80
|
+
| 25 | 250 | NOTICE |
|
|
81
|
+
| 20 (INFO) | 200 | INFO |
|
|
82
|
+
| below 20 | 100 | DEBUG |
|
|
83
|
+
|
|
84
|
+
`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.
|
|
85
|
+
|
|
86
|
+
**Exceptions** go in `context.exception` as `{class, message, code, file, trace, previous}`:
|
|
87
|
+
|
|
88
|
+
- `class` is module-qualified, without `builtins.`.
|
|
89
|
+
- `code` is `args[0]` when it is an int (not a bool). Otherwise it is 0.
|
|
90
|
+
- `file` is `path:line` of the innermost frame.
|
|
91
|
+
- `trace` holds up to 100 `path:line in func` strings, innermost first.
|
|
92
|
+
- `previous` follows `__cause__`, or `__context__` unless it is suppressed. It is recursive and safe against cycles.
|
|
93
|
+
|
|
94
|
+
The dashboard shows the whole chain.
|
|
95
|
+
|
|
96
|
+
**Normalization** follows Monolog's rules:
|
|
97
|
+
|
|
98
|
+
- depth is limited to 9, and each container to 1000 items, using Monolog's marker strings;
|
|
99
|
+
- non-finite floats become strings;
|
|
100
|
+
- other objects become `str()`, and an object that cannot be printed becomes a marker.
|
|
101
|
+
|
|
102
|
+
**Size cap: 256 KiB per line.**
|
|
103
|
+
|
|
104
|
+
1. 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, and `previous` is dropped.
|
|
105
|
+
2. If the line is still too long, only `exception`, `cloud_request_id` and a `truncated` note are kept.
|
|
106
|
+
3. If it is still too long, `context` keeps only the note, and `channel` and the logger name are cut to 16 KiB.
|
|
107
|
+
|
|
108
|
+
The 256 KiB budget includes the trailing newline. Cuts count UTF-8 bytes and never split a character.
|
|
109
|
+
|
|
110
|
+
The result is always valid JSON at the right level.
|
|
111
|
+
|
|
112
|
+
### Why only these seven top-level keys
|
|
113
|
+
|
|
114
|
+
The platform picks the record type from top-level keys:
|
|
115
|
+
|
|
116
|
+
- `source: "nginx-app"` makes the line a fake access log;
|
|
117
|
+
- `logger: "http.log.access.log0"` makes it a Caddy access log;
|
|
118
|
+
- `_cloud_event` takes the line out of the logs;
|
|
119
|
+
- `context` selects the Laravel path.
|
|
120
|
+
|
|
121
|
+
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](https://linear.app/laravel/issue/SE-295) and its comments.
|
|
122
|
+
|
|
123
|
+
## Transport and fallback
|
|
124
|
+
|
|
125
|
+
- **On Cloud** (`LARAVEL_CLOUD=1`), lines go to `LARAVEL_CLOUD_LOG_SOCKET`. When that variable is not set, they go to `unix:///tmp/cloud-init.sock`. Python containers do not set the variable, but the socket exists. Supported addresses are `unix://path`, `tcp://host:port` and `host:port`.
|
|
126
|
+
- **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](https://linear.app/laravel/issue/SE-301).
|
|
127
|
+
- **Connection:**
|
|
128
|
+
- one `sendall` per record, under the handler lock, with a 2 s timeout;
|
|
129
|
+
- it connects on the first record;
|
|
130
|
+
- it reconnects after `fork()`, so Gunicorn workers never share the parent's socket;
|
|
131
|
+
- after a connect or send failure, it waits 5 s before it tries again.
|
|
132
|
+
- **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.
|
|
133
|
+
- The platform splits socket lines over 2 MiB. The 256 KiB cap prevents this.
|
|
134
|
+
|
|
135
|
+
## Limits
|
|
136
|
+
|
|
137
|
+
- Anything printed before `configure()` runs is still plain text at info level. This includes interpreter crash output and server boot lines.
|
|
138
|
+
- The dashboard cannot show whether a line came from stdout or stderr.
|
|
139
|
+
- There is no redaction. Keep secrets out of messages and `extra=` fields.
|
|
140
|
+
- Not in scope: Laravel's Exceptions feature (`_cloud_event: exception`), which is Laravel-only for now.
|
|
141
|
+
|
|
142
|
+
## Development
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
uv run --python 3.14 --group test pytest -q
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
CI runs the tests on Python 3.10 to 3.14. The framework packages are test-only dependencies.
|
|
149
|
+
|
|
150
|
+
### Live check on Laravel Cloud
|
|
151
|
+
|
|
152
|
+
1. Run `python scripts/live_check.py command <env>`. It prints a `cpx cloud command:run` command and a marker. The command carries the package inside `--cmd`, so you do not need to deploy anything.
|
|
153
|
+
2. Run the printed command. It prints the marker and a `from`/`to` window. `command:run` output itself is never logged, but lines sent to the socket are.
|
|
154
|
+
3. Run `python scripts/live_check.py verify <app> <env> <marker> <from> <to>`. It checks:
|
|
155
|
+
- every entry has type `application`, with the right levels;
|
|
156
|
+
- there is exactly one exception entry, with its chain;
|
|
157
|
+
- the request ID is present;
|
|
158
|
+
- 40 of 40 concurrent lines arrived whole;
|
|
159
|
+
- the 600 KB record is still JSON at warning level.
|
|
160
|
+
|
|
161
|
+
The logs API returns at most 100 rows per call, so the script reads in small windows.
|
|
162
|
+
4. Check the dashboard Logs page by hand: the level tags and colours, and the exception chain in the details panel.
|
|
163
|
+
|
|
164
|
+
Run the check on a shared (Flex) environment and on a private one.
|
|
165
|
+
|
|
166
|
+
### Releasing
|
|
167
|
+
|
|
168
|
+
Publishing uses PyPI trusted publishing (OIDC), so the repo stores no tokens. See `.github/workflows/publish.yml`.
|
|
169
|
+
|
|
170
|
+
1. Set `version` in `pyproject.toml`, run `uv lock`, and merge to `main`.
|
|
171
|
+
2. Publish to TestPyPI: run the **Publish** workflow manually on `main` (`gh workflow run publish.yml --ref main`).
|
|
172
|
+
3. Publish to PyPI: create a GitHub release tagged `v<version>` (`gh release create v0.0.1 --generate-notes`). The `pypi` job waits for approval in the `pypi` environment.
|
|
173
|
+
|
|
174
|
+
## License
|
|
175
|
+
|
|
176
|
+
MIT
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "laravel-cloud-logging"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Laravel-style (Monolog JSON) logging for Python apps on Laravel Cloud"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = ["laravel", "laravel-cloud", "logging", "monolog", "json", "django", "flask", "fastapi"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: POSIX :: Linux",
|
|
19
|
+
"Operating System :: MacOS",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Topic :: System :: Logging",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/DGarbs51/laravel-cloud-python-logging"
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
test = ["pytest", "flask", "starlette", "httpx", "django", "celery", "gunicorn", "rq", "uvicorn"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|