Skip to content

Fix the log levels mapping #597

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: devel
Choose a base branch
from
Open

Fix the log levels mapping #597

wants to merge 3 commits into from

Conversation

Jakuje
Copy link
Contributor

@Jakuje Jakuje commented May 23, 2024

SUMMARY

The libssh provides the most verbose logging with SSH_LOG_TRACE, which was mapped to logging.CRITICAL, causing the users being unable to get full debug logs. These are critical for libssh developers to be able to investigate issues.

ISSUE TYPE
  • Bugfix Pull Request

Jakuje added a commit to Jakuje/ansible.netcommon that referenced this pull request May 23, 2024
The `INFO` log level is usually not enough to get useful information from the libssh regarding the low-level issues that we would like to be able to resolve. This can be made even more fine grained, but I do not think less verbose logs would be much useful anyway.

Related is the issue in pylibssh, which maps the DEBUG verbosity to something that is not debug in libssh itself:

ansible/pylibssh#597
@webknjaz
Copy link
Member

It'd be useful to have tests in such PRs. I'm unable to verify what this patch affects without them.

Copy link

Congratulations! One of the builds has completed. 🍾

You can install the built RPMs by following these steps:

  • sudo yum install -y dnf-plugins-core on RHEL 8
  • sudo dnf install -y dnf-plugins-core on Fedora
  • dnf copr enable packit/ansible-pylibssh-597
  • And now you can install the packages.

Please note that the RPMs should be used only in a testing environment.

@psf-chronographer psf-chronographer bot added the bot:chronographer:provided There is a change note present in this PR label Jun 21, 2024
@Jakuje
Copy link
Contributor Author

Jakuje commented Aug 29, 2024

@webknjaz the current version should be working version with tests of what, I hope, you were trying to describe. I think it will need some polishing so I would be happy for your inputs.

Copy link

Copy link
Member

@webknjaz webknjaz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wrote most of the review months ago. Submitting just now. This might need a few more iterations. Hopefully, a few things are actionable immediately.

const char *function,
const char *buffer,
void *userdata) noexcept nogil:
with gil:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this be problematic in free-threading builds of CPython?

@Jakuje
Copy link
Contributor Author

Jakuje commented Apr 11, 2025

Turns out that also the logging.NOTSET is misplaced. In python it means "ask above"/"log everything" but in libssh the currently mapped value libssh.SSH_LOG_NONE means do not log anything, which is quite the opposite so my suggestion would be to move this to logging.WARNING which is probably the closest ...

@Jakuje Jakuje force-pushed the patch-1 branch 3 times, most recently from 808a4aa to 2c034b8 Compare April 11, 2025 09:13
Copy link

codecov bot commented Apr 11, 2025

Welcome to Codecov 🎉

Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests.

Thanks for integrating Codecov - We've got you covered ☂️

@webknjaz
Copy link
Member

webknjaz commented Apr 24, 2025

Turns out that also the logging.NOTSET is misplaced. In python it means "ask above"/"log everything" but in libssh the currently mapped value libssh.SSH_LOG_NONE means do not log anything, which is quite the opposite so my suggestion would be to move this to logging.WARNING which is probably the closest ...

If you want a "log nothing", I'd probably set the threshold high in the mapping. Something like 55 or 60, or even 99:

$ python
Python 3.13.2 (main, Feb 28 2025, 20:34:46) [GCC 14.2.1 20241221] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import logging
>>> logging.getLevelNamesMapping()
{'CRITICAL': 50, 'FATAL': 50, 'ERROR': 40, 'WARN': 30, 'WARNING': 30, 'INFO': 20, 'DEBUG': 10, 'NOTSET': 0}
>>> logging.fatal
<function fatal at 0x7fa77ea62fc0>
>>> logging.CRITICAL
50
>>> logging.FATAL
50
>>> logging.ERROR
40

P.S. I wonder if float('inf') would be allowed since infinity is semantically what you're after...

if level not in LOG_MAP:
raise LibsshSessionException(f'Invalid log level [{level:d}]')

rc = libssh.ssh_set_log_level(LOG_MAP[level])
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this special-case NOTSET and just skip calling this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not clear to me as the libssh mapping and python is that different.

I understand the NOTSET value that we should log everything and let the underlying code handle that. If we would skip this call on NOTSET, we would just keep the previously set log level, which might be TRACE, NONE, or anything in between, which does not sound right to me.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Jakuje so the way the logging infrastructure works in python is that the loggers are called throughout the application code with explicit levels. And then, there's a level of logging set on the logger, which allows disregarding messages below the set level (this is simple integer comparison). But then, there's also handlers of which there can be many. And you can have a handler for stdout, stderr, some file and some network logging service. So a logged message that cleared the first gate (the logger log level) goes into all of the handlers which would have their own logging levels as well. And the handlers can be set up to output a debug log file and a normal log file + normal stderr + critical-only messages going to a network server (as an example).

logging.NOTSET is 0. Meaning that if a logger has logging.NOTSET, no messages are going to be disregarded “on entry”. This is a semantic “log everything”.

>>> import logging
>>> logging.NOTSET
0

I should note, though, that loggers have hierarchy. And so there's a "root logger" and "child" loggers. When a logger has a level of logging.NOTSET, the actual level is being computed by traversing the parent relationships. And only if the root logger has it as logging.NOTSET, this value of 0 is used (otherwise, some other value may end up being considered as the current logging level). logging.NOTSET is not the default of the root logger, logging.WARNING is.

So I'd say logging.NOTSET may be considered a special-cased sentinel in some contexts.

Comment on lines 25 to 53
ANSIBLE_PYLIBSSH_TRACE = int(logging.DEBUG / 2)


LOG_MAP = {
logging.NOTSET: libssh.SSH_LOG_TRACE,
ANSIBLE_PYLIBSSH_TRACE: libssh.SSH_LOG_TRACE,
logging.DEBUG: libssh.SSH_LOG_DEBUG,
logging.INFO: libssh.SSH_LOG_INFO,
logging.WARNING: libssh.SSH_LOG_WARN,
logging.ERROR: libssh.SSH_LOG_WARN,
logging.CRITICAL: libssh.SSH_LOG_NONE,
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we use a sentinel object for "not set" and special-case it later? Plus expose an explicit "no log".

Suggested change
ANSIBLE_PYLIBSSH_TRACE = int(logging.DEBUG / 2)
LOG_MAP = {
logging.NOTSET: libssh.SSH_LOG_TRACE,
ANSIBLE_PYLIBSSH_TRACE: libssh.SSH_LOG_TRACE,
logging.DEBUG: libssh.SSH_LOG_DEBUG,
logging.INFO: libssh.SSH_LOG_INFO,
logging.WARNING: libssh.SSH_LOG_WARN,
logging.ERROR: libssh.SSH_LOG_WARN,
logging.CRITICAL: libssh.SSH_LOG_NONE,
}
ANSIBLE_PYLIBSSH_NOLOG = float('inf')
ANSIBLE_PYLIBSSH_TRACE = int(logging.DEBUG / 2)
_NOT_SET_SENTINEL = object()
LOG_MAP = {
logging.NOTSET: _NOT_SET_SENTINEL,
ANSIBLE_PYLIBSSH_TRACE: libssh.SSH_LOG_TRACE,
logging.DEBUG: libssh.SSH_LOG_DEBUG,
logging.INFO: libssh.SSH_LOG_INFO,
logging.WARNING: libssh.SSH_LOG_WARN,
logging.ERROR: libssh.SSH_LOG_WARN,
logging.CRITICAL: libssh.SSH_LOG_NONE,
ANSIBLE_PYLIBSSH_NOLOG: libssh.SSH_LOG_NONE,
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that duplicate values will cause a smaller LOG_MAP_REV. Should we consider having a smaller dict for determinism and then augmenting it with additional mappings post-reversal?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having seen your hint that logging might require an int, this could be

Suggested change
ANSIBLE_PYLIBSSH_TRACE = int(logging.DEBUG / 2)
LOG_MAP = {
logging.NOTSET: libssh.SSH_LOG_TRACE,
ANSIBLE_PYLIBSSH_TRACE: libssh.SSH_LOG_TRACE,
logging.DEBUG: libssh.SSH_LOG_DEBUG,
logging.INFO: libssh.SSH_LOG_INFO,
logging.WARNING: libssh.SSH_LOG_WARN,
logging.ERROR: libssh.SSH_LOG_WARN,
logging.CRITICAL: libssh.SSH_LOG_NONE,
}
ANSIBLE_PYLIBSSH_NOLOG = logging.FATAL * 2
ANSIBLE_PYLIBSSH_TRACE = int(logging.DEBUG / 2)
_NOT_SET_SENTINEL = object()
LOG_MAP = {
logging.NOTSET: _NOT_SET_SENTINEL,
ANSIBLE_PYLIBSSH_TRACE: libssh.SSH_LOG_TRACE,
logging.DEBUG: libssh.SSH_LOG_DEBUG,
logging.INFO: libssh.SSH_LOG_INFO,
logging.WARNING: libssh.SSH_LOG_WARN,
logging.ERROR: libssh.SSH_LOG_WARN,
logging.CRITICAL: libssh.SSH_LOG_NONE,
ANSIBLE_PYLIBSSH_NOLOG: libssh.SSH_LOG_NONE,
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that duplicate values will cause a smaller LOG_MAP_REV. Should we consider having a smaller dict for determinism and then augmenting it with additional mappings post-reversal?

If we remove the duplicates, we will fail to map in one of the ways. Note, that this maps X->Y and Y->X without any compicated logic like getting the first smaller/larger value. Yes, adding some logic to mapping can make the lists smaller by few items, but I think the complexity of the mapping/matching would not be worth the smaller map (unless there is some simple way to do that in python that I am not aware of).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, libssh.SSH_LOG_WARN maps to one of logging.WARNING or logging.ERROR in LOG_MAP_REV. It's probably predictable, but I can't say which one by just looking at code. If we only keep one in LOG_MAP, then produce LOG_MAP_REV (like it is now) and only then extend LOG_MAP to add “aliases”, it should give us better control over what's in the main mapping vs. the extended one.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the suggestion. This makes sense.


cdef void _pylibssh_log_wrapper(int priority,
const char *function,
const char *buffer,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to myself: buffer is highlighted as blue. This is because buffer() used to be a builtin in Python 2. Python 3 has it as a memoryview().

},
}

logger = logging.getLogger("libssh")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be called libssh? or perhaps

Suggested change
logger = logging.getLogger("libssh")
_logger = logging.getLogger('ansible-pylibssh')

?
Should we be exposing this _logger object to the end-users or just have it internal?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we initialize the logger object in session.pyx (module-level or as an instance on Session) and then pass it to set_level() as an argument instead of having a global thing here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, it should be internal. I think the problem was accessing the logger from the callback _pylibssh_log_wrapper(), which gets called by the libssh. I probably did not want to mess with passing it as a userdata, but it should be an option. Let me have a look into that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, the thing is that the logging is really not tied to the session so if we allocate the logger in session and then free it we might crash, which is why I went with the global.
The other option would be to unset the callback + userdata when session is freed, but it would still be mess if more sessions would get allocated in one thread.

So the only viable option I see could creating some singleton Logging object, encapsulating the logging context, if you prefer.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's okay to have it global, just have it underscored so that it doesn't look like public API.

The libssh provides the most verbose logging with SSH_LOG_TRACE, which
was not mapped to any of the standard values so the users are unable
to get full debug logs. These are critical for libssh developers to be
able to investigate issues.

Signed-off-by: Jakub Jelen <jjelen@redhat.com>
@Jakuje Jakuje force-pushed the patch-1 branch 2 times, most recently from 9f92dcc to 6c229ee Compare April 28, 2025 15:49
Jakuje added 2 commits April 28, 2025 17:54
Signed-off-by: Jakub Jelen <jjelen@redhat.com>
Signed-off-by: Jakub Jelen <jjelen@redhat.com>
Copy link

@Jakuje
Copy link
Contributor Author

Jakuje commented Apr 28, 2025

Ugh. Is the ubi/fedora RPM builds still/again broken? This does not look like an issue in this PR if I see right:

ImportError while loading conftest '/github/home/rpmbuild/BUILD/ansible-pylibssh-1.2.3.dev189+g6432716/tests/conftest.py'.
tests/conftest.py:16: in <module>
    from pylibsshext.logging import ANSIBLE_PYLIBSSH_TRACE
E   ImportError: /github/home/rpmbuild/BUILDROOT/python-ansible-pylibssh-1.2.3.dev189+g6432716-1.el9.x86_64/usr/lib64/python3.9/site-packages/pylibsshext/logging.cpython-39-x86_64-linux-gnu.so: undefined symbol: ssh_set_log_level

const char *buffer,
void *userdata) noexcept:
log_level = LOG_MAP_REV[priority]
_logger.log(log_level, str(buffer))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#597 (comment) revealed that the string conversion is borked here.

str(b'asdf') is converted into "b'asdf'" because it's going through repr().

It should be something like

Suggested change
_logger.log(log_level, str(buffer))
_logger.log(log_level, buffer.decode('utf-8'))

Are we sure what the actual encoding is? If not, this would have to become

Suggested change
_logger.log(log_level, str(buffer))
_logger.log(log_level, buffer.decode('utf-8', errors='surrogateescape'))

(https://docs.python.org/3/library/codecs.html#error-handlers)

@webknjaz
Copy link
Member

Not sure about the RPMs but the test job is slow on Python 3.8: https://github.com/ansible/pylibssh/actions/runs/14712178586/job/41287439642?pr=597#step:16:696

@webknjaz
Copy link
Member

Building things under QEMU was slow and timed out too https://github.com/ansible/pylibssh/actions/runs/14712178586/job/41287253557?pr=597

python log facility.

It provides two new log levels, that can be used with to set the log verbosity
using `set_log_level()` method on `Session` object.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
using `set_log_level()` method on `Session` object.
using ``set_log_level()`` method on ``Session`` object.

@webknjaz
Copy link
Member

Ugh. Is the ubi/fedora RPM builds still/again broken? This does not look like an issue in this PR if I see right:

ImportError while loading conftest '/github/home/rpmbuild/BUILD/ansible-pylibssh-1.2.3.dev189+g6432716/tests/conftest.py'.
tests/conftest.py:16: in <module>
    from pylibsshext.logging import ANSIBLE_PYLIBSSH_TRACE
E   ImportError: /github/home/rpmbuild/BUILDROOT/python-ansible-pylibssh-1.2.3.dev189+g6432716-1.el9.x86_64/usr/lib64/python3.9/site-packages/pylibsshext/logging.cpython-39-x86_64-linux-gnu.so: undefined symbol: ssh_set_log_level

They weren't broken on the last commit to devel 4 days ago.
Back then, F40 used libssh-devel 0.10.6-5.fc40: https://github.com/ansible/pylibssh/actions/runs/14667486098/job/41165904433#step:16:22.
And UBI 9.0.0 used libssh-devel 0.10.4-13.el9: https://github.com/ansible/pylibssh/actions/runs/14667486098/job/41165904860#step:16:33

The UBI 9 job in this PR uses libssh-devel 0.10.4-13.el9 as well. So there's no difference there.

It's a mystery, so far. Can it be that CFLAGS / LDFLAGS is being broken in the upstream images? I'm not sure.

@webknjaz
Copy link
Member

And the container images are the same too.

F40: sha256:3c86d25fef9d2001712bc3d9b091fc40cf04be4767e48f1aa3b785bf58d300ed
UBI9: sha256:9db17fd5d0bbf8797d65d42c039f3a20ce9621d989cc173e3c616fa5e8588115

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bot:chronographer:provided There is a change note present in this PR
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants