-
Notifications
You must be signed in to change notification settings - Fork 183
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
feat(logging): Distinguish logs from different models #1302
Changes from 7 commits
76f8651
b809448
05d3228
3f64211
8902b03
61f3e93
4f27dca
c37065c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
from contextlib import contextmanager | ||
from contextvars import ContextVar | ||
|
||
from .settings import ModelSettings | ||
|
||
model_name_var: ContextVar[str] = ContextVar("model_name") | ||
model_version_var: ContextVar[str] = ContextVar("model_version") | ||
|
||
|
||
@contextmanager | ||
def model_context(model_settings: ModelSettings): | ||
model_name_token = model_name_var.set(model_settings.name) | ||
|
||
model_version = "" | ||
if model_settings.version: | ||
model_version = model_settings.version | ||
model_version_token = model_version_var.set(model_version) | ||
|
||
try: | ||
yield | ||
finally: | ||
model_name_var.reset(model_name_token) | ||
model_version_var.reset(model_version_token) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,15 +2,16 @@ | |
import logging | ||
import sys | ||
|
||
from logging import Formatter, StreamHandler | ||
from logging import StreamHandler | ||
from pathlib import Path | ||
from typing import Optional, Dict, Union | ||
import logging.config | ||
|
||
from .context import model_name_var, model_version_var | ||
from .settings import Settings | ||
|
||
LoggerName = "mlserver" | ||
LoggerFormat = "%(asctime)s [%(name)s] %(levelname)s - %(message)s" | ||
LoggerFormat = "%(asctime)s [%(name)s]%(model)s %(levelname)s - %(message)s" | ||
|
||
logger = logging.getLogger(LoggerName) | ||
|
||
|
@@ -35,13 +36,35 @@ def apply_logging_file(logging_settings: Union[str, Dict]): | |
logger.warning("Unable to parse logging_settings.") | ||
|
||
|
||
class ModelLoggerFormatter(logging.Formatter): | ||
""" | ||
A logging formatter that uses context variables to inject | ||
the model name and version in the log message. | ||
""" | ||
|
||
@staticmethod | ||
def _fmt_model(name: str, version: str) -> str: | ||
if not name: | ||
return "" | ||
model_fmt = f"{name}:{version}" if version else name | ||
Comment on lines
+47
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🙃 Personally I find this slightly hard to read, even if it's concise. Having 3 explicit cases would be much more immediately obvious to me. Assuming Python 3.10, this could use match-case: def fmt(name, version):
match name, version:
case n, _ if not n:
return ""
case n, v if not v:
return f"[{n}]"
case n, v:
return f"[{n}:{v}]" This also only needs 1 format string per case--minor performance improvement and slightly easier to understand what's output. For older Python versions, this could be: if not name:
return ""
elif not version:
return f"[{name}]"
else:
return f"[{n}:{v}]" This is arguably the most legible and broad definition due to the concise use of Boolean contexts. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, the pattern matching looks very elegant. Thanks, Alex! I will refactor it |
||
return f"[{model_fmt}]" | ||
|
||
def format(self, record: logging.LogRecord) -> str: | ||
model_name = model_name_var.get("") | ||
model_version = model_version_var.get("") | ||
|
||
record.model = self.__class__._fmt_model(model_name, model_version) | ||
vtaskow marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
return super().format(record) | ||
|
||
|
||
def configure_logger(settings: Optional[Settings] = None): | ||
logger = get_logger() | ||
|
||
# Don't add handler twice | ||
if not logger.handlers: | ||
stream_handler = StreamHandler(sys.stdout) | ||
formatter = Formatter(LoggerFormat) | ||
formatter = ModelLoggerFormatter(LoggerFormat) | ||
stream_handler.setFormatter(formatter) | ||
|
||
logger.addHandler(stream_handler) | ||
|
vtaskow marked this conversation as resolved.
Show resolved
Hide resolved
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,34 +1,13 @@ | ||
from contextvars import ContextVar | ||
from contextlib import contextmanager | ||
from prometheus_client import Histogram | ||
|
||
from ..settings import ModelSettings | ||
from .registry import REGISTRY | ||
from .errors import InvalidModelContext | ||
|
||
model_name_var: ContextVar[str] = ContextVar("model_name") | ||
model_version_var: ContextVar[str] = ContextVar("model_version") | ||
from ..context import model_name_var, model_version_var | ||
|
||
SELDON_MODEL_NAME_LABEL = "model_name" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💭 Given that the name and version are used in both logging and metrics, it'd be nice to have constants (as far as Python allows) to ensure the same labels are available in both contexts. If the context vars are the source of truth, that be |
||
SELDON_MODEL_VERSION_LABEL = "model_version" | ||
|
||
|
||
@contextmanager | ||
def model_context(model_settings: ModelSettings): | ||
model_name_token = model_name_var.set(model_settings.name) | ||
|
||
model_version = "" | ||
if model_settings.version: | ||
model_version = model_settings.version | ||
model_version_token = model_version_var.set(model_version) | ||
|
||
try: | ||
yield | ||
finally: | ||
model_name_var.reset(model_name_token) | ||
model_version_var.reset(model_version_token) | ||
|
||
|
||
def register(name: str, description: str) -> Histogram: | ||
""" | ||
Registers a new metric with its description. | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
import pytest | ||
|
||
from typing import Optional | ||
|
||
from mlserver.settings import ModelSettings, ModelParameters | ||
from mlserver.context import model_context, model_name_var, model_version_var | ||
from .fixtures import SumModel | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"name, version, expected_version", | ||
[ | ||
( | ||
"foo", | ||
"v1.0", | ||
"v1.0", | ||
), | ||
( | ||
"foo", | ||
"", | ||
"", | ||
), | ||
("foo", None, ""), | ||
], | ||
) | ||
def test_model_context(name: str, version: Optional[str], expected_version: str): | ||
model_settings = ModelSettings( | ||
name=name, implementation=SumModel, parameters=ModelParameters(version=version) | ||
) | ||
|
||
with pytest.raises(LookupError): | ||
_ = model_name_var.get() | ||
|
||
with pytest.raises(LookupError): | ||
_ = model_version_var.get() | ||
|
||
with model_context(model_settings): | ||
var_name = model_name_var.get() | ||
var_version = model_version_var.get() | ||
assert var_name == name | ||
assert var_version == expected_version | ||
|
||
with pytest.raises(LookupError): | ||
_ = model_name_var.get() | ||
|
||
with pytest.raises(LookupError): | ||
_ = model_version_var.get() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,57 @@ | ||
import pytest | ||
|
||
from mlserver.logging import logger, configure_logger | ||
from mlserver.settings import Settings | ||
from mlserver import ModelSettings | ||
from mlserver.context import model_context | ||
from mlserver.logging import ( | ||
ModelLoggerFormatter, | ||
LoggerFormat, | ||
configure_logger, | ||
logger, | ||
) | ||
from mlserver.settings import ModelParameters, Settings | ||
from tests.fixtures import SumModel | ||
from logging import INFO | ||
|
||
|
||
@pytest.mark.parametrize( | ||
"name, version, expected_fmt", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔧 The implementation of the model logger allows the model name to be empty. This case should either be captured by a test case or it shouldn't be permitted because it indicates something has gone wrong (or hasn't been wired up correctly). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good point! I've missed that. Will fix in the incoming PR. |
||
[ | ||
( | ||
"foo", | ||
"v1.0", | ||
"[foo:v1.0]", | ||
), | ||
( | ||
"foo", | ||
"", | ||
"[foo]", | ||
), | ||
( | ||
"foo", | ||
None, | ||
"[foo]", | ||
), | ||
], | ||
) | ||
def test_model_logging_formatter(name, version, expected_fmt, caplog): | ||
caplog.handler.setFormatter(ModelLoggerFormatter(LoggerFormat)) | ||
caplog.set_level(INFO) | ||
|
||
model_settings = ModelSettings( | ||
name=name, implementation=SumModel, parameters=ModelParameters(version=version) | ||
) | ||
|
||
logger.info("Before model context") | ||
with model_context(model_settings): | ||
logger.info("Inside model context") | ||
logger.info("After model context") | ||
|
||
log_records = caplog.text.strip().split("\n") | ||
assert len(caplog.messages) == 3 | ||
|
||
assert expected_fmt not in log_records[0] | ||
assert expected_fmt in log_records[1] | ||
assert expected_fmt not in log_records[2] | ||
|
||
|
||
@pytest.mark.parametrize("debug", [True, False]) | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I thought the general pattern for Python docstrings is that the summary should be a single line, even for multi-line docstrings. E.g.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wasn't aware of this PEP rule. Good spot, I will refactor 👍