Logging and metrics
Records go to the wijjit_ssh logger tree, which is silent until
configured and never carries credentials.
Turning it on
run() configures stderr logging for you -
unless you have already set up a handler on either wijjit_ssh or the root
logger, in which case it stays out of your way entirely.
Everywhere else, call configure_logging() yourself:
import sys
from wijjit_ssh import configure_logging
configure_logging(sys.stderr) # a stream
configure_logging(sys.stderr, level="DEBUG") # ...more of it
configure_logging("wijjit-ssh.log") # a UTF-8 file, appended
configure_logging(None) # silence
stderr is the default because, unlike a Wijjit app - where stderr is the terminal the app is drawing on - an SSH server’s stdout and stderr are ordinary process streams, and stderr is what systemd and Docker collect.
Or ignore this function entirely and configure the wijjit_ssh logger with
logging.config.dictConfig like any other library. It is a normal logger tree
with propagate=True.
Session-bound records
Every session gets a short correlation id, bound with the username and peer address into every line it emits:
2026-07-16 11:04:22 INFO wijjit_ssh.session: [3f9a1c04 ada@10.0.0.7] Session started (term=xterm, 120x40)
2026-07-16 11:09:47 INFO wijjit_ssh.session: [3f9a1c04 ada@10.0.0.7] Closing session: idle_timeout
2026-07-16 11:09:47 INFO wijjit_ssh.session: [3f9a1c04 ada@10.0.0.7] Session ended after 325.0s: idle_timeout
The id is eight hex characters - long enough not to collide in a log file, short enough to sit in every line and still be greppable. It is a correlation handle, not a security token.
That same id is on session.session_id, so surfacing it in your app’s own logs
or in an error dialog is what ties a user’s report back to the server-side
record.
Note
The context is bound to the session object rather than to a
ContextVar for a structural reason: the asyncssh
callbacks (session_started, data_received, connection_lost) run on
asyncssh’s connection task, while the app runs in a task created in
session_started. A contextvar set inside the app task would be invisible
from exactly the callbacks that need to log.
The metrics hook
Pass on_event= to get lifecycle events without this package depending on a
metrics library:
from collections import Counter
counts: Counter[str] = Counter()
def on_event(event: str, fields: Mapping[str, object]) -> None:
counts[event] += 1
WijjitSSH(make_app, host_keys=host_keys, auth=auth, on_event=on_event).run()
Event |
Fields |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Important
Read the payload with fields.get(...) rather than subscripting it.
session.rejected is emitted from two places and they do not carry the same
fields: a session refused by max_sessions has authenticated, so it has a
username, while one refused for requesting no pty is reported without one.
For the same reason, session.ended fires for sessions that never emitted
session.started — a no-pty refusal, or an app factory that raised. A hook
that pairs the two (a gauge, a subscriber registry) has to tolerate an
ended it never saw a started for.
Wiring that to Prometheus is a dozen lines and no dependency on our side:
from prometheus_client import Counter, Histogram
EVENTS = Counter("wijjit_ssh_events", "Lifecycle events", ["event", "reason"])
DURATION = Histogram("wijjit_ssh_session_seconds", "Session duration")
def on_event(event: str, fields: Mapping[str, object]) -> None:
EVENTS.labels(event, str(fields.get("reason", ""))).inc()
if event == "session.ended":
DURATION.observe(float(fields["duration"]))
Important
A hook that raises is logged and swallowed. A deployment’s counter must not be able to take a session down - these callbacks run inside asyncssh callbacks, where an exception would propagate into the transport rather than anywhere useful.
The hook is called on the event loop, so it must not block. Increment a counter; do not write to a database.
Why this package owns its own logger tree
Wijjit’s get_logger roots a name under its own namespace by prefixing
"wijjit." - but only if not name.startswith("wijjit"). Our modules are
named wijjit_ssh.*, which does start with "wijjit", so the prefix is
never applied. The result is a logger called wijjit_ssh.server that is a
sibling of the wijjit tree rather than a child of it.
That is not cosmetic. wijjit.configure_logging() only handles the wijjit
logger, so our loggers inherit none of it - including configure_logging(None),
the “turn logging off” switch. A record from wijjit_ssh would therefore
propagate to the root logger, find no handler anywhere, and be printed to stderr
by logging.lastResort. In a process where a Wijjit TUI has imported
wijjit_ssh, that means a warning sprayed across the alternate screen buffer,
corrupting the frame.
So wijjit_ssh owns its tree, with the conventional library posture:
A
NullHandleris attached at import time.logging.Logger.callHandlers()only falls back tolastResortwhen it finds zero handlers on the chain, so this one handler is what stops the stderr spray.propagateis left True, so a host application that configures the root logger receives our records - which is what a library embedded in someone else’s process should do. It also keeps pytest’scaplogworking.configure_logging()is opt-in.
Note
This module is wijjit_ssh/logging.py, but import logging inside it
resolves to the standard library rather than to itself - Python 3 uses
absolute imports. Importing it as wijjit_ssh.logging is unambiguous.