Skip to content
9 changes: 9 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 3.0.0 (unreleased)

- Feature: Configurable logging — stdout/file/syslog handlers, text/JSON
format, per-package log levels, access log toggle. Default is now stdout
to stderr (12-factor). File logging is opt-in via `log_file: true`.
JSON format requires `python-json-logger`.
`location_log` is deprecated in favor of `log_file_path`.
[@jensens]

## 2.4.1 (2026-03-05)

- Docs: Update PGJsonb default `blob-threshold` from 1MB to 100KB in docs
Expand Down
22 changes: 21 additions & 1 deletion cookiecutter.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,30 @@
{
"_version": "2.4.1.dev0",
"_version": "3.0.0.dev0",
"_extensions": ["local_extensions.ZopeExtensions"],
"target": "instance",
"location_clienthome": "{{ cookiecutter.target }}/var",
"location_log": "{{ cookiecutter.location_clienthome }}/log",

"log_format": "text",
"log_format_class": "",
"log_format_string": "",
"log_level": "INFO",
"log_stdout": true,
"log_stdout_stream": "stderr",
"log_file": false,
"log_file_path": "",
"log_syslog": false,
"log_syslog_host": "localhost",
"log_syslog_port": 514,
"log_syslog_facility": 1,
"log_syslog_protocol": "udp",
"access_log_enabled": true,
"logging_loggers": {
"plone": "INFO",
"waitress.queue": "INFO",
"waitress": "INFO"
},

"wsgi_listen": "localhost:8080",
"wsgi_fast_listen": "",
"wsgi_threads": "4",
Expand Down
125 changes: 125 additions & 0 deletions docs/sources/how-to/configure-logging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Configure logging

<!-- diataxis: how-to -->

How to configure logging for different deployment scenarios.

## Cloud-native deployment (Docker / Kubernetes)

For containers, log to stdout in JSON format.
The 12-factor app methodology recommends treating logs as event streams — write to stdout and let the execution environment handle routing.

```{code-block} yaml
:caption: instance.yaml

default_context:
log_format: json
log_stdout: true
log_stdout_stream: stdout
log_file: false
access_log_enabled: false
log_level: WARNING
logging_loggers:
plone: INFO
```

```{important}
JSON format requires the [python-json-logger](https://pypi.org/project/python-json-logger/) package.
Add it to your `requirements.txt` or `constraints.txt`:

pip install python-json-logger
```

Set `access_log_enabled: false` when your ingress controller or reverse proxy already handles access logging.

## Traditional deployment with file logging

For non-containerized servers, log to files:

```{code-block} yaml
:caption: instance.yaml

default_context:
log_stdout: false
log_file: true
log_file_path: /var/log/plone/instance.log
logging_loggers:
plone: INFO
waitress: WARNING
```

The access log is automatically created alongside the event log (`instance-access.log`).

## Hybrid: stdout and remote syslog

Combine local stdout with centralized log collection via syslog:

```{code-block} yaml
:caption: instance.yaml

default_context:
log_stdout: true
log_syslog: true
log_syslog_host: logserver.internal
log_syslog_protocol: tcp
logging_loggers:
plone: DEBUG
```

```{tip}
Use TCP (`log_syslog_protocol: tcp`) in production to avoid silent message loss with UDP.
```

## Debug a specific package

Increase the log level for specific packages without changing the global level:

```{code-block} yaml
:caption: instance.yaml

default_context:
log_level: WARNING
logging_loggers:
plone: DEBUG
plone.restapi: DEBUG
waitress: WARNING
```

## Use a custom formatter

Point to any Python formatter class:

```{code-block} yaml
:caption: instance.yaml

default_context:
log_format_class: mypackage.logging.MyFormatter
log_format_string: "%(message)s"
```

The custom class must be importable in the Zope instance's Python environment.

## Migrate from `location_log`

```{deprecated} 3.0
The `location_log` setting is deprecated.
Use `log_file_path` instead for explicit control over the log file location.
```

If you currently rely on `location_log`, add `log_file: true` and `log_file_path` to your configuration:

```{code-block} yaml
:caption: instance.yaml — before (deprecated)

default_context:
location_log: /var/log/plone

```

```{code-block} yaml
:caption: instance.yaml — after

default_context:
log_file: true
log_file_path: /var/log/plone/instance.log
```
5 changes: 5 additions & 0 deletions docs/sources/how-to/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ Goal-oriented guides for common tasks.

- {doc}`configure-cors`

## Logging

- {doc}`configure-logging`

## Operations

- {doc}`use-environment-variables`
Expand All @@ -37,6 +41,7 @@ configure-zeo
configure-z3blobs
migrate-storage
configure-cors
configure-logging
use-environment-variables
upgrade-v1-to-v2
```
2 changes: 1 addition & 1 deletion docs/sources/reference/basic-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Core WSGI server, environment, and request-handling settings.

| Setting | Default | Allowed Values |
|---|---|---|
| `location_log` | `{{ cookiecutter.location_clienthome }}/log` | path |
| `location_log` | `{{ cookiecutter.location_clienthome }}/log` | path — **deprecated**, use {doc}`logging` `log_file_path` instead |
| `wsgi_listen` | `localhost:8080` | host:port |
| `wsgi_fast_listen` | *(unset)* | host:port |
| `wsgi_threads` | `4` | integer |
Expand Down
1 change: 1 addition & 0 deletions docs/sources/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ maxdepth: 2
---
base-locations
basic-config
logging
initial-user
zcml
database-common
Expand Down
96 changes: 96 additions & 0 deletions docs/sources/reference/logging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Logging

<!-- diataxis: reference -->

Complete reference for logging configuration variables.

```{versionchanged} 3.0
Logging is now fully configurable. The default changed from file-based logging to stdout (12-factor pattern). If you relied on file logging, add `log_file: true` to your configuration.
```

## Format and level

| Setting | Default | Allowed values |
|---|---|---|
| `log_format` | `text` | `text`, `json` |
| `log_format_class` | *(unset)* | Python dotted name |
| `log_format_string` | *(unset)* | Python logging format string |
| `log_level` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |

**`log_format`** — Output format for all handlers.
`text` uses the classic Zope format: `%(asctime)s %(levelname)-7.7s [%(name)s:%(lineno)s][%(threadName)s] %(message)s`.
`json` uses [python-json-logger](https://pypi.org/project/python-json-logger/) for structured JSON output (requires installation).

**`log_format_class`** — Custom Python formatter class (dotted name). When set, overrides `log_format`. The class must be importable at runtime.

**`log_format_string`** — Format string passed to the custom formatter class.

**`log_level`** — Root logger level. Individual loggers in `logging_loggers` can override this.

## stdout handler

| Setting | Default | Allowed values |
|---|---|---|
| `log_stdout` | `true` | `true`, `false` |
| `log_stdout_stream` | `stderr` | `stdout`, `stderr` |

**`log_stdout`** — Enable logging to stdout or stderr. Default is `true` (12-factor pattern).

**`log_stdout_stream`** — Which stream to use. Default is `stderr`. Container deployments often prefer `stdout`.

## File handler

| Setting | Default | Allowed values |
|---|---|---|
| `log_file` | `false` | `true`, `false` |
| `log_file_path` | *(unset)* | Absolute file path |

**`log_file`** — Enable file-based logging. Default is `false`.

**`log_file_path`** — Full path to the event log file. When unset, falls back to `<location_log>/instance.log`. The access log path is derived by inserting `-access` before the file extension (e.g. `instance.log` → `instance-access.log`).

## Syslog handler

| Setting | Default | Allowed values |
|---|---|---|
| `log_syslog` | `false` | `true`, `false` |
| `log_syslog_host` | `localhost` | hostname or IP |
| `log_syslog_port` | `514` | integer |
| `log_syslog_facility` | `1` | integer 0–23 |
| `log_syslog_protocol` | `udp` | `udp`, `tcp` |

**`log_syslog`** — Enable syslog remote logging via Python's `SysLogHandler`.

**`log_syslog_facility`** — Syslog facility as integer:

| Value | Name |
|---|---|
| 1 | LOG_USER (default) |
| 16 | LOG_LOCAL0 |
| 17 | LOG_LOCAL1 |
| 18 | LOG_LOCAL2 |
| 19 | LOG_LOCAL3 |
| 20 | LOG_LOCAL4 |
| 21 | LOG_LOCAL5 |
| 22 | LOG_LOCAL6 |
| 23 | LOG_LOCAL7 |

**`log_syslog_protocol`** — `udp` (default) or `tcp`. TCP is recommended for production to prevent silent message loss.

## Access log

| Setting | Default | Allowed values |
|---|---|---|
| `access_log_enabled` | `true` | `true`, `false` |

**`access_log_enabled`** — Enable WSGI access logging via the Paste `translogger` filter. When `false`, the `translogger` filter and the `wsgi` logger are removed entirely. Set to `false` when your ingress controller or reverse proxy handles access logging.

## Per-package log levels

| Setting | Default | Allowed values |
|---|---|---|
| `logging_loggers` | `{"plone": "INFO", "waitress.queue": "INFO", "waitress": "INFO"}` | dict of `name: level` |

**`logging_loggers`** — Dict mapping logger names to log levels. The template generates a `[logger_X]` section for each entry. Reserved keys `root` and `wsgi` are silently ignored (they are managed by the template).

Logger names with dots use underscore mapping for ini section names (e.g. `plone.restapi` → `[logger_plone_restapi]`) with `qualname` set to the original dotted name.
28 changes: 27 additions & 1 deletion hooks/post_gen_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,33 @@
# post generation step 2: generate directories
with work_in(basedir):
Path("{{ cookiecutter.location_clienthome }}").mkdir(parents=True, exist_ok=True)
Path("{{ cookiecutter.location_log }}").mkdir(parents=True, exist_ok=True)
# Handle log directory — deprecation of location_log
log_file_path = "{{ cookiecutter.log_file_path }}"
location_log = "{{ cookiecutter.location_log }}"
log_file_enabled = "{{ cookiecutter.log_file }}" == "True"

if log_file_enabled:
if log_file_path:
# log_file_path is explicitly set — use it, create parent dir
log_dir = Path(log_file_path).parent
elif location_log:
# Deprecated fallback
print(
f"Warning: 'location_log' is deprecated and will be removed "
f"in the next major version.\n"
f" Use 'log_file_path' instead.\n"
f" Falling back to '{location_log}/instance.log'.\n"
)
log_dir = Path(location_log)
else:
# Computed default
log_dir = Path("{{ cookiecutter.target }}", "var", "log")
log_dir.mkdir(parents=True, exist_ok=True)
else:
# Even when file logging is off, location_log dir may be needed
# by profile_repoze settings
if location_log:
Path(location_log).mkdir(parents=True, exist_ok=True)
Path("{{ cookiecutter.db_blob_location }}").mkdir(parents=True, exist_ok=True)
Path("{{ cookiecutter.environment['CHAMELEON_CACHE'] }}").mkdir(
parents=True, exist_ok=True
Expand Down
28 changes: 28 additions & 0 deletions hooks/pre_gen_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,34 @@
"The 'db_blobs_location' setting was renamed to 'db_blob_location', please fix your configuration!"
)

# logging configuration validation
log_format = "{{ cookiecutter.log_format }}".lower()
if log_format not in ("text", "json"):
print(f"Error: 'log_format' must be 'text' or 'json', got '{log_format}'!")
exit(1)

log_stdout = "{{ cookiecutter.log_stdout }}"
log_file = "{{ cookiecutter.log_file }}"
log_syslog = "{{ cookiecutter.log_syslog }}"
if log_stdout == "False" and log_file == "False" and log_syslog == "False":
print("Error: At least one log handler must be enabled (log_stdout, log_file, or log_syslog)!")
exit(1)

log_stdout_stream = "{{ cookiecutter.log_stdout_stream }}"
if log_stdout_stream not in ("stdout", "stderr"):
print(f"Error: 'log_stdout_stream' must be 'stdout' or 'stderr', got '{log_stdout_stream}'!")
exit(1)

log_syslog_protocol = "{{ cookiecutter.log_syslog_protocol }}"
if log_syslog_protocol not in ("udp", "tcp"):
print(f"Error: 'log_syslog_protocol' must be 'udp' or 'tcp', got '{log_syslog_protocol}'!")
exit(1)

log_syslog_facility = {{ cookiecutter.log_syslog_facility }}
if not (0 <= log_syslog_facility <= 23):
print(f"Error: 'log_syslog_facility' must be 0-23, got {log_syslog_facility}!")
exit(1)

if upgrade_errors:
print("The following errors prevent cookiecutter-zope-instance from continuing, please fix them:")
for error_msg in upgrade_errors:
Expand Down
12 changes: 12 additions & 0 deletions tests/test_bake_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ def test_bake_with_defaults(cookies):
assert 'zope.ini' in etc_files
assert 'site.zcml' in etc_files

# Verify default logging config
zope_ini = (etc_path / "zope.ini").read_text()
# Default: stdout to stderr, text format
assert "[handler_console]" in zope_ini
assert "args = (sys.stderr,)" in zope_ini
assert "[formatter_generic]" in zope_ini
# Default: no file handlers
assert "[handler_eventlog]" not in zope_ini
# Default: access log enabled
assert "[logger_wsgi]" in zope_ini
assert "translogger" in zope_ini


def test_bake_with_pgjsonb(cookies):
"""Bake with pgjsonb storage and verify generated zope.conf."""
Expand Down
Loading
Loading