Skip to content

Commit 66851d8

Browse files
authored
feat(logging): pluggable custom logger support (#73)
Replaces the hardcoded utility.set_logger() call with a merge-driven resolution pipeline that supports optional custom loggers configured via logger.json in both the framework bundle (config/override/) and the pipeline bundle (pipeline_configs/). Core changes: - src/logger.py: new module — create_default_logger, load/merge logger.json, resolve_logger, CompositeLogger (custom primary + stdout mirror), and the top-level resolve_pipeline_logger entry point - src/config/default/logger.json: shipped default (enabled: false) - src/dlt_pipeline_builder.py: wire resolve_pipeline_logger; call register_bundle_sys_paths() before logger init so src/local/libraries/ modules are importable when the factory is loaded - src/bundle_loader.py: make logger optional to break the init chicken-and-egg - src/utility.py: convert debug print() calls to logger.debug(); add optional logger param to get_data_from_files_parallel - tests/test_logger.py: unit tests for merge, resolve, and CompositeLogger - docs/source/feature_logging.rst: full rewrite; includes custom logger contract and step-by-step structured JSON logger example
1 parent 2250333 commit 66851d8

12 files changed

Lines changed: 1118 additions & 99 deletions

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
v0.13.1
1+
v0.14.0
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# ADR-0003: Pluggable logger architecture (factory pattern + CompositeLogger)
2+
3+
**Date:** 2026-05-17
4+
**Status:** Accepted
5+
**PR:** feature/pluggable-logger (v0.14.0)
6+
7+
---
8+
9+
## Context
10+
11+
The framework previously used a single hard-coded `logging.Logger` instance
12+
(`lakeflowframework`) writing plain-text records to stdout. Customers with structured
13+
logging infrastructure (Application Insights, Splunk, Datadog, internal JSON
14+
pipelines) had no way to integrate the framework's log output without patching
15+
the framework itself.
16+
17+
Requirements gathered from customer scenarios:
18+
19+
- Zero-code integration for loggers whose factory signature is compatible with
20+
`factory(dbutils, spark, **kwargs)` — config-only.
21+
- A thin wrapper path for loggers whose signature is incompatible.
22+
- Pipeline-level `logLevel` must propagate to the custom logger.
23+
- Misconfiguration must never crash the pipeline.
24+
- The default logger must remain the out-of-the-box experience — no opt-out
25+
required from customers who do not need a custom logger.
26+
27+
## Decision
28+
29+
Introduce a **factory-based pluggable logger** configured entirely via
30+
`logger.json` files:
31+
32+
```json
33+
{
34+
"enabled": true,
35+
"module": "my_logger_module",
36+
"factory": "get_logger",
37+
"level": "INFO",
38+
"factory_args": {
39+
"log_to_output": false
40+
}
41+
}
42+
```
43+
44+
**Resolution sequence** (`src/logger.py`, `resolve_pipeline_logger`):
45+
46+
1. Load `logger.json` from the framework bundle (`src/config/override/`) and the
47+
pipeline bundle (`pipeline_configs/`); deep-merge per precedence rules
48+
(framework wins by default; `allow_pipeline_logger_override: true` inverts).
49+
2. If `enabled` is `false`, return the default `lakeflowframework` stdout logger.
50+
3. If `library` is set and not importable, fall back to the default logger with a
51+
warning.
52+
4. Import `module`, call `factory(dbutils, spark, **factory_args)`, validate that
53+
the returned object exposes `debug`, `info`, `warning`, `error`, `critical`,
54+
and `exception`.
55+
5. On any failure (import error, factory error, invalid return type), fall back to
56+
the default logger with a warning — the pipeline never fails due to logging
57+
misconfiguration.
58+
6. Apply `mirror_to_stdout` and `CompositeLogger` wrapping (see ADR-0004).
59+
60+
**Resolved log level injection:** before calling the factory the framework
61+
injects the resolved `level` (Spark `logLevel` pipeline setting wins over the
62+
JSON config value) as a `"level"` key in `factory_args`. Custom factories must
63+
declare `level: str = "INFO"` in their signature.
64+
65+
**`sys.path` ordering:** `register_bundle_sys_paths` is called **before**
66+
`resolve_pipeline_logger` so that modules in `src/local/libraries/` are
67+
importable when the factory is loaded. See ADR-0005.
68+
69+
**Custom logger contract:** the object returned by the factory must implement
70+
`debug`, `info`, `warning`, `error`, `critical`, and `exception`
71+
as `(message, *args, **kwargs)`. It must handle `exc_info`, `stacklevel`, and
72+
`stack_info` kwargs (pop and discard the latter two; resolve `exc_info=True` via
73+
`traceback.format_exc()`). Level checks must be applied before message
74+
formatting (lazy evaluation). See `docs/source/feature_logging.rst` for the
75+
full contract table and reference implementation.
76+
77+
## Consequences
78+
79+
- Customers can integrate any logger whose factory accepts
80+
`(dbutils, spark, **kwargs)` using only `logger.json` — no code changes.
81+
- Customers whose logger requires adaptation write a thin wrapper module in
82+
`src/local/libraries/` or as a cluster-installed package; `logger.json`
83+
points to the wrapper.
84+
- Pipeline startup always succeeds regardless of logging misconfiguration.
85+
- The framework default logger (`lakeflowframework` / plain-text stdout) remains the
86+
default; no action needed from customers who do not require a custom logger.
87+
- The `level` kwarg injection is part of the public factory contract — custom
88+
logger authors must handle it.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# ADR-0004: `mirror_to_stdout` defaults to `false`; default logger silenced when custom logger is active
2+
3+
**Date:** 2026-05-17
4+
**Status:** Accepted
5+
**PR:** feature/pluggable-logger (v0.14.0)
6+
7+
---
8+
9+
## Context
10+
11+
When a custom logger is active there are two potential sources of stdout output:
12+
13+
1. The custom logger itself (e.g. structured JSON written via `print()`).
14+
2. The `lakeflowframework` Python `logging.Logger` which was already writing
15+
plain-text lines to stdout before the custom logger existed.
16+
17+
Without an explicit policy both sources emit simultaneously, producing duplicate
18+
or mixed-format output in the Databricks pipeline **Logs** UI. This is
19+
confusing and wastes log ingestion quota for customers routing logs to external
20+
systems.
21+
22+
Two use-cases must be supported:
23+
24+
- **Custom logger owns stdout** — structured JSON only; plain-text lines from
25+
the default logger must be suppressed.
26+
- **Dual output** — custom logger forwards to an external system _and_ the
27+
plain-text stdout mirror is retained for easy in-UI inspection
28+
(`mirror_to_stdout: true`).
29+
30+
## Decision
31+
32+
`mirror_to_stdout` defaults to **`false`**.
33+
34+
When a custom logger initialises successfully and `mirror_to_stdout` is `false`
35+
(default):
36+
37+
- All handlers are removed from the `lakeflowframework` `logging.Logger` instance.
38+
- A `logging.NullHandler` is attached so any code holding a direct
39+
`logging.getLogger("lakeflowframework")` reference stays quiet.
40+
- The custom logger is returned directly — it is the sole output path.
41+
42+
When `mirror_to_stdout` is `true`:
43+
44+
- The custom logger is wrapped in a **`CompositeLogger`** together with the
45+
default `lakeflowframework` stdout handler.
46+
- Every log call is forwarded to the custom logger first (primary), then to the
47+
default logger (mirror).
48+
- `CompositeLogger` passes `*args` and `**kwargs` through to the mirror logger
49+
unchanged so formatting and metadata are not lost.
50+
- `CompositeLogger.close()` closes both loggers.
51+
52+
When the custom logger fails to load, `mirror_to_stdout` has no effect — the
53+
default logger is returned unchanged.
54+
55+
## Consequences
56+
57+
- Customers whose custom logger writes to stdout will not see duplicate lines by
58+
default.
59+
- Customers who want dual output set `mirror_to_stdout: true` and (if their
60+
custom logger also writes to stdout) suppress that output via a factory arg
61+
(e.g. `log_to_output: false`).
62+
- The `lakeflowframework` `logging.Logger` instance is modified in-place when
63+
silenced. Any code that obtained a direct `logging.getLogger("lakeflowframework")`
64+
reference before logger resolution will stop producing output — this is the
65+
intended behaviour.
66+
- `mirror_to_stdout: false` is the breaking change relative to the previous
67+
hard-coded stdout logger. Teams relying on plain-text stdout output and also
68+
enabling a custom logger must set `mirror_to_stdout: true` explicitly.
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# ADR-0005: `sys.path` registration decoupled from logger initialisation
2+
3+
**Date:** 2026-05-17
4+
**Status:** Accepted
5+
**PR:** feature/pluggable-logger (v0.14.0)
6+
7+
---
8+
9+
## Context
10+
11+
`DLTPipelineBuilder.__init__` must perform two steps in sequence:
12+
13+
1. Register `src/local/libraries/` and other bundle paths on `sys.path`
14+
(`register_bundle_sys_paths`) so that custom modules are importable.
15+
2. Resolve the pipeline logger (`resolve_pipeline_logger`), which may import a
16+
custom logger module from one of those paths.
17+
18+
The original implementation of `register_bundle_sys_paths` required a fully
19+
initialised logger to emit `INFO` messages about which paths were registered.
20+
This created a circular dependency:
21+
22+
- The logger cannot be resolved until `sys.path` is registered (the custom
23+
logger module may live in `src/local/libraries/`).
24+
- `sys.path` registration required a logger to report what it was doing.
25+
26+
The temporary fix of passing a bare `logging.getLogger` instance worked but
27+
meant the framework always emitted plain-text `INFO` lines during bootstrap
28+
regardless of whether a custom logger was configured — the opposite of the
29+
intent.
30+
31+
## Decision
32+
33+
Make the `logger` parameter **optional** (`logger=None`) in
34+
`register_bundle_sys_paths`, `_add_if_dir`, and `_warn_legacy_extensions`
35+
in `bundle_loader.py`. All logging calls inside these functions are guarded by
36+
`if logger is not None:`.
37+
38+
`DLTPipelineBuilder.__init__` calls `register_bundle_sys_paths` **silently**
39+
(without a logger) early in bootstrap, before `resolve_pipeline_logger` is
40+
called. The log messages about which paths were registered are therefore emitted
41+
only when the caller explicitly passes a logger — for example, during
42+
reregistration triggered by a reload.
43+
44+
```python
45+
# bootstrap order in DLTPipelineBuilder.__init__
46+
self._load_mandatory_paths()
47+
register_bundle_sys_paths(self.framework_path, self.bundle_path) # silent
48+
self.logger = pipeline_logger.resolve_pipeline_logger(...) # custom module now importable
49+
self.logger.info("Initializing Pipeline...")
50+
```
51+
52+
## Consequences
53+
54+
- Custom logger modules placed in `src/local/libraries/` are guaranteed to be
55+
on `sys.path` before the factory import is attempted.
56+
- Bootstrap `sys.path` registration produces no log output. This is acceptable
57+
because any misconfiguration (missing path, permission error) raises an
58+
exception rather than a log message.
59+
- Callers that want diagnostic output from `register_bundle_sys_paths` must pass
60+
a logger explicitly — the function signature is backward-compatible (existing
61+
callers passing a logger continue to work unchanged).
62+
- Future extensions to `bundle_loader` should follow the same optional-logger
63+
pattern to avoid reintroducing the circular dependency.

0 commit comments

Comments
 (0)