<!-- AUTO-GENERATED — do not edit this file directly.
     Source of truth: conformance/suite/rules/logging.py
     To regenerate:  uv run atlan-application-sdk-conformance gen-rule-docs
     To check CI staleness: uv run atlan-application-sdk-conformance gen-rule-docs --check -->

# Logging Rules (L-series)

**21 rules** · Checker: `suite.checks.logging` (AST-based)

Suppress a finding on the violating line or the line directly above it:

```python
# conformance: ignore[L001] intentional: dynamic message
```

| ID | Name | Tier | Scope | Category | Autofixable | Since |
|---|---|---|---|---|---|---|
| [L001](#l001) | `FStringInLogMessage` | `block` | `both` | `log-format` | yes | 0.4.0 |
| [L002](#l002) | `NonCanonicalLoggerFactory` | `block` | `both` | `log-format` | yes | 0.4.0 |
| [L003](#l003) | `ExtraKwargsWrongFramework` | `warn` | `both` | `log-format` | — | 0.4.0 |
| [L004](#l004) | `ExceptBlockMissingExcInfoLog` | `block` | `both` | `missing-traceback` | yes | 0.4.0 |
| [L005](#l005) | `PrintInProductionCode` | `warn` | `both` | `log-format` | yes | 0.4.0 |
| [L006](#l006) | `InfoInTightLoop` | `warn` | `both` | `log-level` | — | 0.4.0 |
| [L007](#l007) | `LoggerCriticalUsage` | `warn` | `both` | `log-level` | yes | 0.4.0 |
| [L008](#l008) | `UnguardedExpensiveDebug` | `warn` | `both` | `log-performance` | — | 0.4.0 |
| [L009](#l009) | `WarnThenRaiseDuplication` | `warn` | `both` | `log-noise` | — | 0.4.0 |
| [L010](#l010) | `CredentialInLogOutput` | `block` | `both` | `security` | — | 0.4.0 |
| [L011](#l011) | `StringConcatenationInLog` | `block` | `both` | `log-format` | yes | 0.4.0 |
| [L012](#l012) | `StdlibExtraReservedKeyCollision` | `block` | `both` | `log-crash` | — | 0.4.0 |
| [L013](#l013) | `StdlibArbitraryKwargs` | `block` | `both` | `log-crash` | yes | 0.4.0 |
| [L014](#l014) | `StructlogEventKwargOverwrite` | `warn` | `both` | `log-format` | — | 0.4.0 |
| [L015](#l015) | `DictConfigDisableExistingLoggers` | `warn` | `both` | `log-config` | yes | 0.4.0 |
| [L016](#l016) | `BasicConfigNoopAfterFirstCall` | `warn` | `both` | `log-config` | — | 0.4.0 |
| [L017](#l017) | `LoggerExceptionUsage` | `warn` | `both` | `log-level` | yes | 0.4.0 |
| [L018](#l018) | `KwargsInApplicationLogCalls` | `warn` | `both` | `log-format` | — | 0.4.0 |
| [L019](#l019) | `DiscardedBindResult` | `warn` | `both` | `log-config` | — | 0.4.0 |
| [L020](#l020) | `DeprecatedLoggingWarn` | `warn` | `both` | `log-format` | yes | 0.4.0 |
| [L021](#l021) | `MissingLoggingLintRules` | `warn` | `both` | `log-config` | yes | 0.4.0 |

---

## L001 — `FStringInLogMessage` {#l001}

**Tier:** `block` · **Scope:** `both` · **Category:** `log-format` · **Autofixable:** yes · **Since:** 0.4.0

> f-string in log message — breaks log grouping and aggregation

**Rationale:** %-style message bodies are the fleet-wide logging convention: one consistent call-site
style keeps log statements legible and reviewable, and the SDK's loguru bridge renders
the values in. Beyond consistency, %-style now carries a real performance guarantee: the
SDK adapter short-circuits before interpolation when the level is filtered, so __str__
is never called on the arguments — the same laziness stdlib logging provides for free.
f-strings always evaluate eagerly at the call site regardless of level, so they pay the
formatting cost even when the record is never emitted.

Using an f-string creates a unique message string per call, breaking log grouping and
aggregation in Grafana/ClickHouse.  It also always evaluates eagerly — __str__ /
__format__ is called on every interpolated value even when the level is filtered and the
record is never emitted.  %-style avoids both: the SDK adapter's _is_enabled guard
short-circuits before interpolation, so argument __str__ is skipped entirely for
filtered levels.  Rewrite as %-style message body: embed context directly in the format
string, do not move values to kwargs.

---

## L002 — `NonCanonicalLoggerFactory` {#l002}

**Tier:** `block` · **Scope:** `both` · **Category:** `log-format` · **Autofixable:** yes · **Since:** 0.4.0

> Non-canonical logger factory — use `from application_sdk.observability.logger_adaptor import get_logger`

**Rationale:** The SDK adapter (application_sdk.observability.logger_adaptor.get_logger) is the only
sanctioned way to obtain a logger. It injects Temporal context (workflow_id, run_id,
activity_type) as top-level indexed columns in ClickHouse/Grafana and routes records
through OTel. Direct use of logging.getLogger(), structlog.get_logger(), or loguru's
logger bypasses all of this — correlation IDs are lost and records may not reach the
observability store. Promoted from warn to block (CNCT-108, parent CNCT-93): rolled-own
loggers strip correlation_id/workflow context/source provenance, making those lines
unfindable on the tenant UI.

Every module must obtain its logger via the SDK adapter:

```python
from application_sdk.observability.logger_adaptor import get_logger
logger = get_logger(__name__)
```

Direct use of `logging.getLogger()`, `structlog.get_logger()`, or `from loguru import
logger` bypasses the adapter that:

* injects Temporal context fields (`workflow_id`, `run_id`,   `activity_type`,
`task_queue`, `attempt`) as top-level indexed   columns in ClickHouse/Grafana; * routes
log records through OTel so they appear in the observability   store; * enforces the
project's five-level model   (DEBUG/INFO/WARNING/ERROR/CRITICAL).

Adapter definition files are exempt — the file that defines `AtlanLoggerAdapter` or
`get_logger` itself is skipped.  Dev harnesses are exempt too: files under `scripts/`
and `run_dev*.py` never run inside a workflow, so the provenance argument does not apply
(test files are already excluded by discovery).  Block-tier since CNCT-108 (parent
CNCT-93): a rolled-own logger loses correlation IDs and source provenance, making its
records unfindable on the tenant UI.

---

## L003 — `ExtraKwargsWrongFramework` {#l003}

**Tier:** `warn` · **Scope:** `both` · **Category:** `log-format` · **Autofixable:** — · **Since:** 0.4.0

> extra={} used where framework expects direct kwargs (or vice versa)

**Rationale:** Whether kwargs land in indexed top-level fields or an unindexed nested dict depends on
the framework. The wrong form routes context where aggregation queries can't reach —
present in the record but invisible to GROUP BY/filter.

Whether `extra={}` is correct depends on the logging framework.  For structlog and
loguru, `extra={}` is usually wrong — the data lands in an unindexed nested dict
invisible to aggregation queries.  Framework-dependent classification; the checker must
detect the active framework first.

---

## L004 — `ExceptBlockMissingExcInfoLog` {#l004}

**Tier:** `block` · **Scope:** `both` · **Category:** `missing-traceback` · **Autofixable:** yes · **Since:** 0.4.0

> logger.warning/error in except block without exc_info=True

**Rationale:** Same failure as E005 at the logging layer: the message appears in the stream but the
stack trace is absent, so every postmortem hitting this pattern must reproduce the
failure to find root cause.

Logging an exception without `exc_info=True` produces a message with no stack trace —
the root cause is invisible.  Add `exc_info=True` to all `logger.warning()` /
`logger.error()` calls within an except block. `.exception()` is exempt.

Exempt: calls whose arguments flow through a recognised redaction helper
(redact*/sanitiz*/safe_traceback/scrub_secret*/mask_secret*) — these mark a deliberate
no-traceback boundary where exc_info=True would serialize the raw exception past the
sanitizer and can leak credentials (JDBC URLs, Authorization headers, OAuth bodies).

---

## L005 — `PrintInProductionCode` {#l005}

**Tier:** `warn` · **Scope:** `both` · **Category:** `log-format` · **Autofixable:** yes · **Since:** 0.4.0

> print() in production code — bypasses logging framework

**Rationale:** print() bypasses the logging adapter entirely: no level, no correlation ID, no
structured fields, no OTel forwarding. In containers, stdout may route to a different
sink or interleave with structured lines, invisible to observability.

`print()` produces no level, no structured fields, no correlation IDs. In production
services, output may go to stdout unformatted, be lost, or interleave with structured
log lines.  Acceptable in CLI scripts, test/debug scripts, and `if __name__ ==
"__main__":` blocks.

Exempt: standalone scripts/CLIs — files with a shebang or an if __name__ == '__main__'
guard. For those, stdout is the user interface, not a logging bypass.

---

## L006 — `InfoInTightLoop` {#l006}

**Tier:** `warn` · **Scope:** `both` · **Category:** `log-level` · **Autofixable:** — · **Since:** 0.4.0

> logger.info() inside a tight loop — generates excessive log volume

**Rationale:** Per-item INFO in a large loop emits O(N) records at the level operators monitor,
drowning lifecycle signals in noise and inflating storage cost. INFO is for milestones;
per-item progress belongs at DEBUG.

Per-item INFO logging in a large loop drowns meaningful signals and degrades
performance.  INFO is for lifecycle milestones, not per-item events.  Use DEBUG per-item
and INFO for the loop summary.  The checker should inspect whether the loop is clearly
bounded (≤10 items) before flagging.

---

## L007 — `LoggerCriticalUsage` {#l007}

**Tier:** `warn` · **Scope:** `both` · **Category:** `log-level` · **Autofixable:** yes · **Since:** 0.4.0

> logger.critical() — CRITICAL is not a meaningful level here

**Rationale:** ADR-0011 codifies exactly four levels (DEBUG/INFO/WARNING/ERROR) — there is no CRITICAL.
Fatal conditions are communicated through process exit codes and Temporal workflow
failure, not a log level, so a CRITICAL record adds a fifth level nothing in the stack
is built to consume. Use ERROR (with exc_info=True) and let the failure propagate.

CRITICAL is not a meaningful level in distributed systems — every service failure is
"critical" from some perspective.  Use ERROR and handle severity through alerting rules
on the observability platform.

---

## L008 — `UnguardedExpensiveDebug` {#l008}

**Tier:** `warn` · **Scope:** `both` · **Category:** `log-performance` · **Autofixable:** — · **Since:** 0.4.0

> Expensive computation in logger.debug() argument — evaluates eagerly

**Rationale:** Python evaluates all function arguments before calling the log method, so an expensive
expression in a log argument — json.dumps(big), obj.serialize(), a comprehension — runs
unconditionally even when the level is filtered. The SDK adapter's _is_enabled guard
short-circuits %-style __str__ interpolation for simple object args, but it fires inside
the method, after Python has already evaluated every argument expression. Calls with
expensive argument expressions still need an explicit guard.

Python evaluates all arguments before calling the log method, so expensive expressions
in log arguments run on every call regardless of level:

    logger.debug('snapshot: %s', json.dumps(big_dict))  # json.dumps always runs

Note: the SDK adapter's _is_enabled guard does skip %-style __str__ calls for simple
object args (logger.debug('x: %s', obj) — obj.__str__ not called when filtered).  But
that guard fires inside the method, after Python has evaluated the argument expressions.
When the expensive work is in the expression itself, an explicit level guard is still
required:

```python
if logger.isEnabledFor(logging.DEBUG):
    logger.debug('snapshot: %s', json.dumps(big_dict))
```

---

## L009 — `WarnThenRaiseDuplication` {#l009}

**Tier:** `warn` · **Scope:** `both` · **Category:** `log-noise` · **Autofixable:** — · **Since:** 0.4.0

> logger.warning/error immediately before raise — duplicate log records

**Rationale:** Logging immediately before re-raising creates two records for one event (raise site +
handler), inflating error counts and making 'how many times did this fail?' unanswerable
without dedup logic.

Logging an error immediately before re-raising creates duplicate records in the log
stream, inflating error counts in dashboards.  Acceptable only when adding context not
available to the caller.  Otherwise: just re-raise.

---

## L010 — `CredentialInLogOutput` {#l010}

**Tier:** `block` · **Scope:** `both` · **Category:** `security` · **Autofixable:** — · **Since:** 0.4.0

> Credential/secret value in log output — security vulnerability

**Rationale:** Log aggregation stores records in plaintext accessible to more people and systems than
the credential store. A credential value in a log is a persistent exposure that survives
rotation and is indexed for search.

Credentials in log output are a security vulnerability — logs are often stored in
plaintext in log aggregation systems, accessible to more people than the credential
store.  Requires human security review before marking acceptable.  Logging a credential
*name* is acceptable; logging a credential *value* is CRITICAL.

Exempt: arguments assigned a redaction placeholder in the module (e.g. password =
"[REDACTED]" if creds.get("password") else None) — logging them is a presence indicator,
not a value leak.

---

## L011 — `StringConcatenationInLog` {#l011}

**Tier:** `block` · **Scope:** `both` · **Category:** `log-format` · **Autofixable:** yes · **Since:** 0.4.0

> String concatenation in log message — breaks log grouping

**Rationale:** Same convention as L001: string concatenation is an ad-hoc alternative to the standard
%-style message body. It reads worse at the call site and breaks fleet-wide consistency
for no benefit; rewrite as a %-style message body.

Like f-strings (L001), string concatenation embeds values into the message string in a
way that breaks log grouping.  Rewrite as %-style message body.

---

## L012 — `StdlibExtraReservedKeyCollision` {#l012}

**Tier:** `block` · **Scope:** `both` · **Category:** `log-crash` · **Autofixable:** — · **Since:** 0.4.0

> extra={} key collides with stdlib LogRecord attribute — crashes caller

**Rationale:** stdlib's Logger.makeRecord() raises KeyError when an extra={} key collides with a
LogRecord attribute, propagating to the caller's logger.info() site and crashing it. The
22 forbidden keys include natural choices: name, message, module, args, filename.

stdlib's `Logger.makeRecord()` raises `KeyError` if any key in `extra={}` matches a
`LogRecord` attribute.  This crash propagates directly to the caller — NOT caught by
`handleError()`.  The 22 forbidden keys include: `name`, `message`, `module`, `args`,
`filename`, `process`, `thread`.  Applies to stdlib only.

---

## L013 — `StdlibArbitraryKwargs` {#l013}

**Tier:** `block` · **Scope:** `both` · **Category:** `log-crash` · **Autofixable:** yes · **Since:** 0.4.0

> Arbitrary kwargs in stdlib logger — raises TypeError immediately

**Rationale:** stdlib logger.info() raises TypeError immediately for any kwarg outside its short
allowlist. The most common breakage when migrating from structlog (which accepts
arbitrary kwargs) — call sites look identical but fail at runtime.

stdlib `logger.info()` only accepts `exc_info`, `extra`, `stack_info`, and `stacklevel`.
Any other kwarg raises `TypeError` and crashes the caller.  Very common when migrating
from structlog/loguru. Applies to stdlib only.

---

## L014 — `StructlogEventKwargOverwrite` {#l014}

**Tier:** `warn` · **Scope:** `both` · **Category:** `log-format` · **Autofixable:** — · **Since:** 0.4.0

> event= kwarg in structlog silently overwrites the log message

**Rationale:** In structlog the first positional arg is the message (stored as 'event'). Passing event=
as a keyword silently replaces the message with the domain value, corrupting both
message and field in one call.

In structlog, the first positional argument is stored as the `event` key — it IS the log
message.  Passing `event=` as a keyword argument silently overwrites the message with
the domain value.  Rename the domain field to avoid collision.  Applies to structlog
only.

---

## L015 — `DictConfigDisableExistingLoggers` {#l015}

**Tier:** `warn` · **Scope:** `both` · **Category:** `log-config` · **Autofixable:** yes · **Since:** 0.4.0

> dictConfig without disable_existing_loggers=False silently kills loggers

**Rationale:** dictConfig() defaults disable_existing_loggers=True, silently disabling every logger
created before the call. SDK components create loggers at import — before any app
dictConfig() — so a misconfigured call makes all library logging vanish with no error.

`logging.config.dictConfig()`'s `disable_existing_loggers` defaults to `True`, which
silently disables all loggers created before the call.  This is the most common source
of "why is my logging not working?".  Always set `"disable_existing_loggers": False`.
Applies to stdlib only.

---

## L016 — `BasicConfigNoopAfterFirstCall` {#l016}

**Tier:** `warn` · **Scope:** `both` · **Category:** `log-config` · **Autofixable:** — · **Since:** 0.4.0

> Multiple basicConfig() calls — second+ are silent no-ops

**Rationale:** basicConfig() is silently ignored if the root logger already has handlers. Multiple
calls rely on import order to decide which wins; the rest are silently dropped.

`logging.basicConfig()` is silently ignored if the root logger already has handlers.
Multiple calls across the codebase mean whichever runs first wins; the rest are dropped
silently.  Flag any codebase with more than one `basicConfig()` call outside `if
__name__ == "__main__":` blocks. Applies to stdlib only.

---

## L017 — `LoggerExceptionUsage` {#l017}

**Tier:** `warn` · **Scope:** `both` · **Category:** `log-level` · **Autofixable:** yes · **Since:** 0.4.0

> logger.exception() used — use logger.error(..., exc_info=True) instead

**Rationale:** logger.exception() is rejected outright. ADR-0011 restricts logging to four levels with
exc_info=True as the sanctioned way to attach a traceback; logger.exception() implies a
distinct level, reads sys.exc_info() implicitly (empty/stale outside an active except
block), and overlaps the explicit exc_info rules. Use logger.error(..., exc_info=True)
instead.

`logger.exception()` is not a sanctioned logging method in this project. ADR-0011
restricts app logging to four levels (DEBUG/INFO/WARNING/ERROR) and `exc_info=True` is
the canonical way to attach a traceback.  Beyond that, `logger.exception()` reads
`sys.exc_info()` implicitly — capturing nothing (or a stale exception) when called
outside an active except block. Replace every call site with `logger.error(...,
exc_info=True)`.

Checker note: the `AtlanLoggerAdapter`'s own `exception()` shim is exempt — it exists
only to satisfy third-party Temporal callers and immediately delegates to
`self.error(..., exc_info=True)`.

---

## L018 — `KwargsInApplicationLogCalls` {#l018}

**Tier:** `warn` · **Scope:** `both` · **Category:** `log-format` · **Autofixable:** — · **Since:** 0.4.0

> kwargs in application log calls — use %-style message body instead

**Rationale:** The adapter auto-injects Temporal context (workflow/run/activity IDs) as the only
top-level indexed columns in ClickHouse/Grafana. App kwargs land in an unindexed JSON
blob aggregation can't reach — context belongs in the message body via %-style.

Arbitrary kwargs in log calls are an anti-pattern in this project. Framework context
(Temporal fields, correlation IDs) is auto-injected by the logging adapter; all other
kwargs land in an unindexed JSON blob invisible in the log stream.  Embed context
directly in the message body using %-style formatting.

---

## L019 — `DiscardedBindResult` {#l019}

**Tier:** `warn` · **Scope:** `both` · **Category:** `log-config` · **Autofixable:** — · **Since:** 0.4.0

> logger.bind() result discarded — bind() returns a new logger

**Rationale:** structlog and loguru bind() returns a *new* logger with the bound context — the original
is unchanged. A bare call (result not assigned) constructs the context and immediately
discards it; the log call that follows has no extra context attached.

`structlog` and `loguru` `bind()` returns a *new* bound logger; the original is
unchanged.  A bare `logger.bind(key=value)` expression discards the result, so the
context is never attached to any log call. Assign the result: `log =
logger.bind(key=value)`.

---

## L020 — `DeprecatedLoggingWarn` {#l020}

**Tier:** `warn` · **Scope:** `both` · **Category:** `log-format` · **Autofixable:** yes · **Since:** 0.4.0

> logger.warn() is deprecated — use logger.warning() instead

**Rationale:** logging.warn() is a long-deprecated alias for logging.warning(). It emits
DeprecationWarning at import time in newer Python versions and will be removed. The fix
is a trivial rename.

`logger.warn()` / `logging.warn()` is a deprecated alias for `logger.warning()` that
will be removed in a future Python version. Rename every call site to
`logger.warning(...)`.

---

## L021 — `MissingLoggingLintRules` {#l021}

**Tier:** `warn` · **Scope:** `both` · **Category:** `log-config` · **Autofixable:** yes · **Since:** 0.4.0

> pyproject.toml ruff config is missing logging lint rules (G001, G003, G004, T201, LOG009)

**Rationale:** The conformance suite catches logging anti-patterns at review time; ruff catches the
same issues at edit time and in pre-commit. The two are complementary — ruff gives
faster feedback in the IDE, conformance gives auditable SARIF output. Without the ruff
rules enabled, engineers get no in-editor signal for L001/L005/L011/L020 equivalents.

The project's `[tool.ruff.lint]` `select` / `extend-select` must cover the following
rules (or their category prefixes, or `ALL`):

* `G001` — `logging.warn()` deprecated (overlaps L020) * `G003` — string concatenation
in log message (overlaps L011) * `G004` — f-string in log message (overlaps L001) *
`T201` — `print()` statement (overlaps L005) * `LOG009` — `logging.warn()` deprecated
(overlaps L020)

A rule is covered if its full ID, any prefix (e.g. `G` covers all `G`-prefixed rules),
or `ALL` appears in `select` or `extend-select` and is not in `ignore` /
`extend-ignore`.

Pin the five rules individually. Selecting the bare `G` category satisfies this check
but also enables `G201`, which demands `.exception(...)` over `.error(...,
exc_info=True)` — the exact inverse of conformance L017 (LoggerExceptionUsage). With `G`
selected, ruff and the conformance suite contradict each other on every except-block log
call.

Self-check exemption: `pyproject.toml` files whose `[project].name` starts with
`atlan-application-sdk` are skipped (the SDK's own tooling config is managed
separately).

---
