Internal reference for the SDK's logging framework. Companion to ITERATORS.md, MODELS.md, RESOURCE.md.
The framework is designed to be silent by default (library best practice — no logs unless the caller opts in), integrated with stdlib logging (so it composes with the user's existing setup), and safe (bearer tokens and other credentials are redacted before they ever reach a handler).
PYTFE_LOG=debug python my_script.pysetup_logging() is invoked automatically when the pytfe package is imported, so the env var is the entire user surface — no code change required. Programmatic equivalent (handy in tests or in REPLs where the env var was set after import):
import pytfe
pytfe.setup_logging()setup_logging() is idempotent — calling it more than once is safe.
That's it. Anything between DEBUG-level HTTP request/response traces and INFO-level retry decisions will show up on stderr with a per-line format like:
[2026-05-25 14:12:26 pytfe.transport DEBUG]
> GET /api/v2/organizations/acme/workspaces?page[number]=1&page[size]=100
< 200 OK
< {
< "data": [
< { "id": "ws-...", "type": "workspaces", ... }
< ]
< }
| Logger | What it logs |
|---|---|
pytfe |
Root namespace; rarely emits directly. Use it to dial everything pytfe says up or down at once. |
pytfe.transport |
HTTP request/response (DEBUG), retry decisions (INFO), transport exceptions (DEBUG). The noisy one. |
There is no pytfe.resource.* per-service logger. Resource methods do not log; if a caller needs visibility into "the SDK is calling `client.workspaces.read('ws-abc')'" they get it via the transport log right below it.
Standard stdlib selectors apply:
import logging
logging.getLogger("pytfe").setLevel(logging.INFO) # everything
logging.getLogger("pytfe.transport").setLevel(logging.DEBUG) # just HTTPThe framework has three environment variables. All are optional.
| Variable | Default | Effect |
|---|---|---|
PYTFE_LOG |
unset | debug or info (case-insensitive) configures stdlib logging for you. Anything else is ignored. |
PYTFE_LOG_HEADERS |
false |
When truthy, include request/response headers in RoundTrip output. Sensitive ones are still redacted; this just turns on the > * Header: value lines at all. |
PYTFE_LOG_TRUNCATE_BYTES |
1024 |
Truncation budget for any single string in a logged body. Values below 96 are clamped up. |
All three are read at call time, not at import — switching PYTFE_LOG_HEADERS=true in the middle of a long-running process takes effect on the next request.
The RoundTrip formatter (in src/pytfe/_logging.py) redacts before formatting, so the redacted value never reaches the logger:
Headers — replaced with **REDACTED** when matched. Names matched case-insensitively:
authorization
cookie
set-cookie
proxy-authorization
x-tfc-task-signature
Plus any header whose name contains the substring token, secret, password, api-key, or apikey.
JSON bodies — when the response body is JSON, these top-level and nested keys have their values replaced (case-insensitive key match):
token, access_token, refresh_token,
secret, password,
private_key, client_secret
This is structural: a value can be redacted even if it's deep inside an array of nested objects. String values themselves are not scanned for tokens — only the keys are matched. If you stuff a bearer token into a field named "description", it will appear in the log.
The **REDACTED** constant is exported from pytfe._logging if you ever need to assert on it in a test.
Bodies are formatted, not echoed:
- JSON arrays beyond the budget are clipped with
"... (N additional elements)". - JSON string values longer than the per-string budget are clipped with
"... (N more bytes)". - Non-JSON bodies are shown verbatim (after the same per-string truncation).
- Binary bodies (state-version downloads, CV tarballs, anything with a non-text/non-JSON
Content-Type) are rendered as[raw stream]— the body is not decoded or formatted.
This keeps a --list over a 10,000-workspace organization to one screen of log output instead of 10MB.
src/pytfe/_http.py emits:
| Event | Logger | Level | Cost when disabled |
|---|---|---|---|
| Every HTTP request/response round-trip | pytfe.transport |
DEBUG | Zero — guarded by isEnabledFor(DEBUG). The RoundTrip object is only constructed when the level is enabled. |
Retry decisions (429, 5xx, Retry-After) |
pytfe.transport |
INFO | One conditional + format-string evaluation. |
| Transport exceptions during retry loop | pytfe.transport |
DEBUG | Zero (same guard pattern). |
There is no DEBUG cost when logging is off, even on 10k-request workloads.
If you're adding code under src/pytfe/, prefer the framework over print or ad-hoc logging.getLogger(__name__):
# resources/something.py
from .._logging import logger
def some_operation(self, foo):
if logger.isEnabledFor(logging.INFO):
logger.info("performing some_operation on %s", foo)
...Two rules:
- Always guard non-trivial log argument construction with
isEnabledFor. Don't pay format/serialize cost when the level is off. - Never log a token, password, or other secret yourself. Only
RoundTripknows how to redact, and it only redacts what it knows about. If you're tempted to writelogger.info("got token %s", token)— don't.
For low-level transport additions, use transport_logger (also exported from pytfe._logging).
- Not a metrics framework. No counters, gauges, timing histograms. If you want metrics, wrap the client.
- Not an audit log. Logs are for debugging, not for compliance trails.
- Not a tracing framework. No correlation IDs, no OpenTelemetry spans. Standard stdlib
loggingonly. - Not Ansible-aware. When the Ansible collection imports pytfe, the
pytfelogger inherits from Ansible's root logger like any other library — which means it stays silent unless the Ansible user explicitly raises the level. No special integration is needed or provided.
- New library log calls use
pytfe._logging.loggerorpytfe._logging.transport_logger, notlogging.getLogger(__name__)ad hoc - Anything more expensive than a literal format string is guarded with
isEnabledFor(...) - No raw tokens, passwords, or other credentials in any log call
- If logging a header dict, it goes through
redact_headers(...) - If logging a request/response, it uses
RoundTrip(resp).generate()so the standard redaction + truncation applies - Logger default level is unchanged (i.e. NullHandler still active for callers who don't opt in)