Skip to content

Document structured logging and define failure ownership - #748

Open
tony wants to merge 35 commits into
masterfrom
document-logging
Open

Document structured logging and define failure ownership#748
tony wants to merge 35 commits into
masterfrom
document-logging

Conversation

@tony

@tony tony commented Aug 22, 2026

Copy link
Copy Markdown
Member

Closes #744.

Summary

  • Document how applications enable libtmux logging, interpret levels, read structured tmux_ fields, assert on records with caplog, and select emitted fields through standard-library handlers and formatters.
  • Standardize command and object lifecycle context with consistent, optional socket, subcommand, target, session, window, and pane fields.
  • Define failure ownership: propagated failures stay in exceptions, expected probes remain quiet, and lenient server-list accessors emit one ERROR when they return an empty result.
  • Normalize missing, non-executable, and malformed tmux binaries to TmuxCommandNotFound while preserving operating-system diagnostics; unrelated launch failures remain native.
  • Aggregate recoverable option-parsing warnings and add structured control-mode and hook diagnostics.

Changes by area

Command and lifecycle records

  • Command execution: tmux_cmd produces dispatch and completion records with a complete one-line argv, best-effort subcommand and socket context, exit status, bounded output snapshots, and total line counts.
  • Object lifecycle: Server, session, window, and pane operations use a shared string-valued schema for affected objects and targets.
  • Control characters: Caller-provided identity and command values use escaped representations so they cannot create additional rendered log lines.
  • Control mode: Client startup and forced termination carry the same command context as normal tmux subprocess records.

Failure handling

  • Propagated failures: Direct and translated failures remain exception data instead of being logged and re-raised.
  • Lenient accessors: Server.sessions, Server.attached_sessions, and Server.clients retain their empty-result contract and emit one bounded diagnostic where the failure is swallowed.
  • Health checks: Server.is_alive() remains quiet, while Server.raise_if_dead() retains loud failure semantics.
  • Executable launches: TmuxCommandNotFound preserves the operating-system message and cause when execution was attempted; unrelated resource errors remain OSError.

Recoverable diagnostics

  • Option parsing: Malformed terminal-features, terminal-overrides, and command-alias entries produce one aggregate warning per option with the skipped-entry count.
  • Hooks and lookups: Parse and lookup diagnostics use structured context without duplicate traceback output.
  • Session lifecycle: Server.kill_session() emits the same lifecycle schema as object-level session operations.

Documentation

  • Logging guide: The new topic documents logger ownership, levels, structured fields, failure routing, payload policy, handler filtering, formatter defaults, and caplog assertions.
  • Executable examples: Documentation examples run through the repository doctest environment, including the caplog fixture.
  • Changelog: The unreleased notes describe the failure-channel and executable-exception changes, structured records, aggregate warnings, and logging guide.

Design decisions

  • Applications own payload policy: DEBUG records retain complete command operands and bounded output snapshots. libtmux does not guess which values are secrets; handlers and formatters decide which fields reach each destination.
  • Derived fields are best effort: The complete command remains authoritative. Subcommand and socket extraction stops at unknown global options instead of inventing context.
  • Exceptions and logs have separate owners: A failure is logged only where libtmux converts it to a fallback result. Callers own logging for failures they receive as exceptions.

Verification

$ uv run ruff format . --check
$ uv run ruff check .
$ uv run mypy
$ uv run pytest --reruns=0
$ just build-docs

Test plan

  • Verify command dispatch and completion schemas, output bounds, Unicode, control-character escaping, unknown global options, and complete large operands.
  • Verify server, session, window, pane, floating-pane, replacement, and all-except lifecycle records against live tmux.
  • Verify swallowed list failures log once while propagated failures and health checks do not emit duplicate errors.
  • Verify missing, inaccessible, malformed, and resource-exhausted executable paths preserve the intended exception types and diagnostics.
  • Verify logging records remain isolated across threaded and async command execution.
  • Execute the logging guide as doctests and build the Sphinx documentation.
  • Show the output-bound regression test failing under a deliberate cap change and passing after restoration.
  • Exercise the logging suite across supported Python versions and the CI matrix across supported tmux releases.
  • Add committed coverage for every aggregate-warning family, blank hook output, and forced control-mode termination.

tony added 27 commits August 22, 2026 15:15
why: tmux_cmd reported two different strings depending on which module
logged it — the full argument vector in common.py, the arguments alone in
neo.py — so a consumer parsing the documented field saw two shapes. The
same command line also carried environment values verbatim, putting any
token passed as environment= into DEBUG logs.

what:
- Add _internal/log_context.py: describe_command() walks tmux's global
  flags per the getopt string in tmux.c, so a flag that takes a value is
  never read as the subcommand, and redacts environment values
- Redact at the seam rather than at each call site, so set-environment
  and any future env-carrying command are covered; -e stays untouched
  where tmux treats it as a boolean (select-pane, copy-mode)
- Derive tmux_subcommand and a new tmux_socket for every command record
- Drop neo.py's duplicate query records, now fully superseded
- Log ERROR in raise_if_stderr, where libtmux decides a command failed,
  carrying the exit code and socket the exception message cannot
- Cover the tmux CLI grammar and redaction with table-driven tests
why: Five near-identical blocks hand-rolled the same lifecycle extra
dict, each repeating the "coerce to str, omit when None" invariant, so
the invariant could drift per call site with nothing to catch it.

what:
- Route lifecycle records through log_context.object_extra(), which
  holds that invariant in one place and is unit-tested
- Tag session created with tmux_target, which it was missing
- Give the hooks parse warning an extra; its %s always interpolated the
  blank line that triggered it
- Drop exc_info from the query_list lookup debug records: a missing
  field is normal control flow for a filter, not a traceback
why: Closes #744. Nothing in the docs mentioned logging, so the only way
to configure it was to read the source and infer a strategy — which is
what the reporter had to do while diagnosing libtmux on a platform it
does not support.

what:
- Add docs/topics/logging.md: turning it on, diagnosing a failure, which
  logger emits what, the full tmux_ field schema, reading records with
  caplog, and recipes for JSON output and OpenTelemetry
- Document the Formatter trap that drops records whose format string
  names a field they lack, with the defaults= fix
- Expose caplog through doctest_namespace so the page's examples run as
  tests rather than drifting from the code
- Record the new tmux_socket key and the failure-record rule in AGENTS
why: Killing a session through Server.kill_session() emitted no
lifecycle record while Session.kill() emitted one, so whether the
operation appeared in logs depended on which entry point a caller
happened to use.

what:
- Emit the same "session killed" INFO record from Server.kill_session()
- Cover it alongside the other lifecycle records
why: The session fixture asked tmux to switch a client even when none
was attached, which is a command failure. Now that libtmux logs failures
at ERROR, pytest replays that record into the captured-log section of
every failing test that uses the fixture, where it reads as the cause of
the failure rather than as fixture noise.

what:
- Attempt the switch only when a client exists; keep the suppression for
  the race where one detaches in between
- Assert fixture setup leaves no ERROR records behind
why: raise_if_stderr() has 85 call sites, so its ERROR record reported
common.py for every one of them. That leaves %(filename)s, %(funcName)s,
and OpenTelemetry's code.filepath naming the helper rather than the tmux
operation that failed — the one thing the record exists to locate.

what:
- Log with stacklevel=2 so the record names the calling wrapper
- Parse bundled global flags: -2Lsock is -2 -L sock to getopt, and the
  socket was being dropped
- Warn once per option rather than once per malformed entry, counting
  the skipped entries in tmux_option_skipped
- Give the control-mode client the same command records as any other
  tmux subprocess, and warn when one must be killed after declining to
  exit; fold its two duplicate terminate paths into one
why: The level table listed only parse failures under WARNING, which
stopped being the whole story once the control-mode client gained a
record for being killed.

what:
- Name the control-mode kill alongside the parse warnings
why: A tmux command line carries whatever the caller passed it, and
send_keys payloads routinely contain newlines. One of those ended the
DEBUG record early in any line-oriented log, leaving the rest of the
argument to read as a record of its own — 66 of the 10,545 commands a
full test run issues already carry control characters.

what:
- Quote an argument holding control characters into shell $'…' form,
  which bash and zsh expand back to the original bytes, so the command
  line still pastes and reproduces the call on a single line
- Render terminal escape sequences rather than passing them through to
  whatever reads the log
- Leave object names alone: tmux rejects control characters in them
  before libtmux logs anything, so there is nothing to guard
why: A command past tmux's own size limit is rejected by tmux, but its
whole payload was still written to three records, so one oversized
set_buffer() could put hundreds of kilobytes into a DEBUG log.

what:
- Cap tmux_cmd at MAX_IMSGSIZE, the largest command tmux will run, so
  every command tmux accepted stays reproducible from its record and
  only a rejected one arrives shortened
- Report the pre-cap length as tmux_cmd_len, matching how the stdout and
  stderr caps already report theirs
why: tmux_stdout and tmux_stderr hold lists, so a %s in a format string
renders a Python repr rather than the lines a reader expects.

what:
- Show the repr and how to join the lines instead
why: The cap measures the quoted command line while tmux measures the
raw one, and quoting expands — a single quote becomes five characters.
A payload of 4000 quotes is a command tmux accepts whose record still
shortens, so "every command tmux ran is reproducible" was not true.

what:
- Describe what the cap actually guarantees, and name quote expansion
- Point at comparing tmux_cmd_len to spot a shortened record
- Filter rather than index in the example, as the page advises
why: Redaction only covered what libtmux sends. tmux returns the same
values on stdout, so getenv() and show_environment() wrote to a DEBUG
record the very value set_environment() kept out of one — an ordinary
call leaking the secret a careful one had protected.

what:
- Redact values in the output of show-environment, so its records show
  the names and the calls still return what they always did
- Cover the round trip in one test rather than one per direction
why: capture-pane, show-buffer, and list-buffers return whatever was on
a screen or in a paste buffer, and every line went into a DEBUG record —
so capturing a pane logged its contents, including anything typed at a
prompt. The caller already holds that value as a return value, so the
record only duplicated it, at size.

what:
- Report tmux_stdout_len alone for content-returning subcommands, the
  line HTTP clients draw between a content length and a body
- Leave control output alone, so list rows and message results still
  reach a debug record
why: The command line is the bulk of debug volume, because libtmux
queries tmux with large -F templates and records each command twice so
either line stands alone. A reader wanting lifecycle events without that
had no way to know which logger to raise.

what:
- Name the cost and point at the per-logger lever that avoids it
why: The filter-placement example set a level on the shared libtmux
logger and never restored it, so every test after the docs page ran with
libtmux at DEBUG. It also removed its filter by index, which is the
wrong one if anything else added a filter first.

what:
- Restore the previous level and remove the filter by identity
- Say why in the prose, since a level on a shared logger outlives the
  code that set it
- Note which output rule wins when a subcommand matches both
why: When tmux returns a row that does not split into the fields the
template asked for, the exception names only zip() — not tmux, not the
subcommand, not the field. The log filled none of that in: tmux itself
succeeded, so the command records showed a clean exit and nothing at all
reported the failure, even at DEBUG.

what:
- Log ERROR with the subcommand and the field counts before the parse
  raises; a count one over expected is what identifies a value holding
  the separator rows are split on
- Keep the ValueError as-is. Converting it to LibTmuxException would let
  the lenient list accessors swallow it, turning a loud failure into a
  silent empty result
- Correct the AGENTS note that said a missing format field raises
  KeyError; Formatter.format raises ValueError and the record is dropped
why: libtmux raises roughly as many advisories through warnings.warn as
it logs — a deprecated method, a flag the running tmux is too old for, an
argument being ignored. None reach a logging handler, so an application
configured exactly as the topic describes received none of them, and the
level table promised the opposite.

what:
- Show captureWarnings() routing them to the py.warnings logger, proved
  both ways as a doctest
- Note that warnings deduplicate per source location and log records do
  not, so a single warning may stand for many occurrences
- Correct the level table: WARNING covers what libtmux resolved itself,
  not deprecations
why: tmux_cmd_len rode on all 17,468 records of a suite run to make
truncation detectable, but the shortened line already ends in an
ellipsis, and across 10,545 real commands nothing truncated at all. The
cap earns its place; the companion key did not.

what:
- Keep the cap and the ellipsis, drop tmux_cmd_len from the schema
- Prove the logging path holds no shared mutable state, and pin it: the
  handler lock makes logging thread-safe, so the only race libtmux could
  add is one list reaching two records
- Document threads and asyncio, including the QueueHandler formatter
  that drops records before they reach the queue
why: Command spellings and specialized failure paths could bypass the
logging safety contract, while lifecycle records did not consistently
identify their tmux server.

what:
- Resolve protected command families without rewriting raw subcommands
- Withhold sensitive content and escape Unicode log boundaries
- Centralize failure records while preserving public exceptions
- Attach socket context and prove concurrent record isolation
why: The logging guide and changelog must describe the final safety
boundaries without overstating tmux transport or shell guarantees.

what:
- Make configuration and integration examples independently executable
- Document sensitive-output, alias, and handler-filter boundaries
- Align failure, socket, and bounded-command changelog entries
why: Bundled tmux environment flags and default aliases could bypass
the logging safety policy, while health probes reported expected
executable failures.

what:
- Parse boolean option clusters before environment values
- Cover output-bearing default aliases
- Keep executable failures quiet only in health-check context
- Add regression coverage for accepted spellings and binaries
why: The guide overstated quiet failures and alias coverage, and did
not explain how fixture-backed examples execute.

what:
- Explain the Python shell and pytest execution contexts
- State unusable-binary, bundled-option, and alias boundaries
- Align the unreleased changelog with the final behavior
why: The root AGENTS.md is now a router that points at
src/libtmux/AGENTS.md for this package's logging conventions. This
branch's logging documentation was written against the old layout,
where the root file still held the conventions themselves.

what:
- Relocate the branch's logging-doc additions into
  src/libtmux/AGENTS.md, matching that file's heading depth and wrap
- Cover where the schema is built, redaction in both directions, the
  handler-filter constraint, thread safety, and failure records
- Add the tmux_option_skipped and tmux_socket keys, route deprecations
  through warnings.warn, and correct the missing-field failure mode to
  ValueError
why: Propagated failures already carry their context in exceptions, while logging before every raise required suppression state and duplicate call-site plumbing.

what:
- Remove low-level log-before-raise and ContextVar suppression
- Emit one ERROR where lenient server accessors return an empty result
- Restore optimistic fixture switching without an extra tmux query
- Keep health checks and propagated failures quiet
why: Command-specific redaction duplicated tmux grammar and still required policy updates for aliases and future commands. Operation metadata is useful without retaining caller arguments or process output.

what:
- Replace the redaction engine with a compact operation summarizer
- Omit command operands and stdout/stderr bodies from records
- Preserve socket, subcommand, exit code, and line-count metadata
- Reuse the same safe summary for subprocess and control-mode logs
why: The logging suite repeated tmux setup, record filtering, and schema assertions for each lifecycle method, obscuring the actual contract.

what:
- Cover lifecycle call sites through one shared capture helper and matrix
- Retain all-except, propagation, privacy, fixture, and warning contracts
- Remove redundant payload variants and mutable-state concurrency coverage
- Prove the consolidated lifecycle test fails on a missing context field
why: The guide, changelog, and package policy still described deleted redaction machinery and repeated generic Python logging recipes.

what:
- Document operand and output-body omission as the privacy boundary
- Describe boundary-only errors and the reduced structured schema
- Keep concise activation, formatter, warning, and caplog examples
- Collapse the changelog to the branch's net behavior
@codecov

codecov Bot commented Aug 22, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.88976% with 23 lines in your changes missing coverage. Please review.
✅ Project coverage is 53.53%. Comparing base (036c521) to head (6078496).

Files with missing lines Patch % Lines
src/libtmux/_internal/log_context.py 81.96% 10 Missing and 1 partial ⚠️
src/libtmux/_internal/control_mode.py 50.00% 4 Missing ⚠️
src/libtmux/options.py 75.00% 3 Missing ⚠️
src/libtmux/common.py 87.50% 2 Missing ⚠️
src/libtmux/_internal/query_list.py 50.00% 1 Missing ⚠️
src/libtmux/hooks.py 0.00% 1 Missing ⚠️
src/libtmux/server.py 93.75% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #748      +/-   ##
==========================================
+ Coverage   52.37%   53.53%   +1.16%     
==========================================
  Files          26       27       +1     
  Lines        3729     3743      +14     
  Branches      747      740       -7     
==========================================
+ Hits         1953     2004      +51     
+ Misses       1472     1459      -13     
+ Partials      304      280      -24     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

tony added 2 commits August 22, 2026 19:41
why: List accessors promise an empty result for tmux failures, but
non-executable and malformed binaries escaped as raw OSError values.

what:
- Translate process-launch OSError values to TmuxCommandNotFound
- Cover missing, non-executable, and malformed tmux binaries
why: Caller-controlled lifecycle identities could contain Unicode line
separators and split rendered records.

what:
- Escape non-printable lifecycle identities in object_extra
- Exercise the target field with a live tmux session name
tony added 6 commits August 23, 2026 06:29
why: Resource-related process launch failures were mislabeled as a
missing tmux executable and lost their diagnostics.

what:
- Reserve TmuxCommandNotFound for unavailable executables
- Retain other launch errors as LibTmuxException causes
why: tmux 3.3a stores Unicode session names as escaped bytes, so the
live delete-by-name step failed before reaching the log assertion.

what:
- Restore a portable name for the live lifecycle flow
- Check the forged target at the shared context boundary
why: The branch replaced released command and output fields with a
summary and presented payload omission as a privacy boundary. That
reduced diagnostics and made application logging policy a producer
concern.

what:
- Restore complete one-line commands and bounded output snapshots
- Stop derived metadata parsing when a global option is unknown
- Include bounded exception text where list accessors swallow failures
- Document handler filters, formatter field selection, and payload cost
why: Unusable executable paths were normalized for lenient accessors,
but the translation discarded the operating-system message and cause.
Server.raise_if_dead() also exposed different exception types.

what:
- Preserve ENOENT, EACCES, and ENOEXEC diagnostics on domain exceptions
- Align tmux_cmd and raise_if_dead without changing successful calls
- Keep lenient accessors empty and attach their diagnostics
- Cover default, missing, non-executable, malformed, and E2BIG cases
why: The test asked select() about the raw descriptor, then read from
TextIOWrapper's user-space buffer. tmux writes a whole control block in
one burst, so the expected line could be buffered while select() timed out.

what:
- Read decoded lines on a daemon thread through a bounded queue
- Preserve reader exceptions and distinguish EOF from timeout
- Verify the Unicode case across tmux 3.2a through 3.7
why: The branch wrapped every unrelated launch OSError in
LibTmuxException. That changed trunk behavior for E2BIG and similar
failures even though only unavailable executables need normalization.

what:
- Re-raise unrelated launch OSError values unchanged from direct APIs
- Catch and log those errors only at lenient list boundaries
- Document direct and lenient exception behavior
- Cover all three list accessors and supported tmux builds
@tony tony changed the title Document logging, and harden the records it emits Document structured logging and define failure ownership Aug 23, 2026
@tony
tony force-pushed the document-logging branch from 6078496 to 8504144 Compare August 23, 2026 17:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Documentation: how to use internal libtmux logging

1 participant