Skip to content

Commit 6252ebd

Browse files
sfc-gh-jkewsfc-gh-joshisfc-gh-mvashishthasfc-gh-dpetersohn
authored
FEAT-modin-project#7445: Add metrics interface so third-parties can collect metrics from the modin frontend (modin-project#7444)
Adds an interface for collecting frontend API statistics by third party systems. Specifically this will be used by Snowflake's pandas engine for Modin to collect data on interactive use cases. Signed-off-by: John Kew <[email protected]> Co-authored-by: Jonathan Shi <[email protected]> Co-authored-by: Mahesh Vashishtha <[email protected]> Co-authored-by: Devin Petersohn <[email protected]>
1 parent bb29f89 commit 6252ebd

File tree

9 files changed

+276
-4
lines changed

9 files changed

+276
-4
lines changed

.github/workflows/ci.yml

+1
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ jobs:
156156
- run: python -m pytest modin/tests/interchange/dataframe_protocol/base
157157
- run: python -m pytest modin/tests/test_dataframe_api_standard.py
158158
- run: python -m pytest modin/tests/test_logging.py
159+
- run: python -m pytest modin/tests/test_metrics.py
159160
- uses: ./.github/actions/upload-coverage
160161

161162
test-defaults:

docs/usage_guide/advanced_usage/index.rst

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Advanced Usage
1010
progress_bar
1111
modin_xgboost
1212
modin_logging
13+
modin_metrics
1314
batch
1415
modin_engines
1516

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
Modin Metrics
2+
=============
3+
4+
Modin allows for third-party systems to register a metrics handler to collect specific API statistics.
5+
Metrics have a name and a value, can be aggregated, discarded, or emitted without impact to the program.
6+
7+
CPU load, memory usage, and disk usage are all typical metrics; but modin currently only emits metrics on API timings which can be used to optimize end-user interactive performance. New metrics may
8+
be added in the future.
9+
10+
It is the responsibility of the handler to process or forward these metrics. The name of the metric will
11+
be in "dot format" and all lowercase, similar to graphite or rrd. The value is an integer or float.
12+
13+
Example metric names include:
14+
15+
* 'modin.core-dataframe.pandasdataframe.copy_index_cache'
16+
* 'modin.core-dataframe.pandasdataframe.transpose'
17+
* 'modin.query-compiler.pandasquerycompiler.transpose'
18+
* 'modin.query-compiler.basequerycompiler.columnarize'
19+
* 'modin.pandas-api.series.__init__'
20+
* 'modin.pandas-api.dataframe._reduce_dimension'
21+
* 'modin.pandas-api.dataframe.sum'
22+
23+
Handlers are functions of the form: `fn(str, int|float)` and can be registered with:
24+
25+
.. code-block:: python
26+
27+
import modin.pandas as pd
28+
from modin.logging.metrics import add_metric_handler
29+
30+
def func(name: str, value: int | float):
31+
print(f"Got metric {name} value {value}")
32+
33+
add_metric_handler(func)
34+
35+
.. warning::
36+
A metric handler should be non-blocking, returning within 100ms, although this is not enforced. It must not throw exceptions or it will
37+
be deregistered. These restrictions are to help guard against the implementation of a metrics collector which would impact
38+
interactice performance significantly. The data from metrics should generally be offloaded to another system for processing
39+
and not involve any blocking network calls.
40+
41+
Metrics are enabled by default. Modin metrics can be disabled like so:
42+
43+
.. code-block:: python
44+
45+
import modin.pandas as pd
46+
from modin.config import MetricsMode
47+
MetricsMode.disable()

modin/config/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
LogMemoryInterval,
3737
LogMode,
3838
Memory,
39+
MetricsMode,
3940
MinColumnPartitionSize,
4041
MinPartitionSize,
4142
MinRowPartitionSize,
@@ -108,6 +109,7 @@
108109
"LogMode",
109110
"LogMemoryInterval",
110111
"LogFileSize",
112+
"MetricsMode",
111113
# Plugin settings
112114
"DocModule",
113115
]

modin/config/envvars.py

+25
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,31 @@ def get(cls) -> int:
624624
return log_file_size
625625

626626

627+
class MetricsMode(EnvironmentVariable, type=ExactStr):
628+
"""
629+
Set ``MetricsMode`` value to disable/enable metrics collection.
630+
631+
Metric handlers are registered through `add_metric_handler` and can
632+
be used to record graphite-style timings or values. It is the
633+
responsibility of the handler to define how those emitted metrics
634+
are handled.
635+
"""
636+
637+
varname = "MODIN_METRICS_MODE"
638+
choices = ("enable", "disable")
639+
default = "enable"
640+
641+
@classmethod
642+
def enable(cls) -> None:
643+
"""Enable all metric collection."""
644+
cls.put("enable")
645+
646+
@classmethod
647+
def disable(cls) -> None:
648+
"""Disable all metric collection."""
649+
cls.put("disable")
650+
651+
627652
class PersistentPickle(EnvironmentVariable, type=bool):
628653
"""Whether serialization should be persistent."""
629654

modin/logging/__init__.py

+4
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,14 @@
1414
from .class_logger import ClassLogger # noqa: F401
1515
from .config import get_logger # noqa: F401
1616
from .logger_decorator import disable_logging, enable_logging # noqa: F401
17+
from .metrics import add_metric_handler, clear_metric_handler, emit_metric
1718

1819
__all__ = [
1920
"ClassLogger",
2021
"get_logger",
2122
"enable_logging",
2223
"disable_logging",
24+
"emit_metric",
25+
"add_metric_handler",
26+
"clear_metric_handler",
2327
]

modin/logging/logger_decorator.py

+12-4
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@
1616
1717
``enable_logging`` is used for decorating individual Modin functions or classes.
1818
"""
19-
2019
from __future__ import annotations
2120

2221
from functools import wraps
22+
from time import perf_counter
2323
from types import FunctionType, MethodType
2424
from typing import Any, Callable, Dict, Optional, Tuple, TypeVar, overload
2525

2626
from modin.config import LogMode
27+
from modin.logging.metrics import emit_metric
2728

2829
from .config import LogLevel, get_logger
2930

@@ -121,8 +122,11 @@ def decorator(obj: Fn) -> Fn:
121122

122123
assert isinstance(modin_layer, str), "modin_layer is somehow not a string!"
123124

124-
start_line = f"START::{modin_layer.upper()}::{name or obj.__name__}"
125-
stop_line = f"STOP::{modin_layer.upper()}::{name or obj.__name__}"
125+
api_call_name = f"{name or obj.__name__}"
126+
log_line = f"{modin_layer.upper()}::{api_call_name}"
127+
metric_name = f"{modin_layer.lower()}.{api_call_name.lower()}"
128+
start_line = f"START::{log_line}"
129+
stop_line = f"STOP::{log_line}"
126130

127131
@wraps(obj)
128132
def run_and_log(*args: Tuple, **kwargs: Dict) -> Any:
@@ -140,13 +144,17 @@ def run_and_log(*args: Tuple, **kwargs: Dict) -> Any:
140144
-------
141145
Any
142146
"""
147+
start_time = perf_counter()
143148
if LogMode.get() == "disable":
144-
return obj(*args, **kwargs)
149+
result = obj(*args, **kwargs)
150+
emit_metric(metric_name, perf_counter() - start_time)
151+
return result
145152

146153
logger = get_logger()
147154
logger.log(log_level, start_line)
148155
try:
149156
result = obj(*args, **kwargs)
157+
emit_metric(metric_name, perf_counter() - start_time)
150158
except BaseException as e:
151159
# Only log the exception if a deeper layer of the modin stack has not
152160
# already logged it.

modin/logging/metrics.py

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Licensed to Modin Development Team under one or more contributor license agreements.
2+
# See the NOTICE file distributed with this work for additional information regarding
3+
# copyright ownership. The Modin Development Team licenses this file to you under the
4+
# Apache License, Version 2.0 (the "License"); you may not use this file except in
5+
# compliance with the License. You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software distributed under
10+
# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
11+
# ANY KIND, either express or implied. See the License for the specific language
12+
# governing permissions and limitations under the License.
13+
14+
"""
15+
Module contains metrics handler functions.
16+
17+
Allows for the registration of functions to collect
18+
API metrics.
19+
"""
20+
21+
import re
22+
from typing import Callable, Union
23+
24+
from modin.config.envvars import MetricsMode
25+
26+
metric_name_pattern = r"[a-zA-Z\._\-0-9]+$"
27+
_metric_handlers: list[Callable[[str, Union[int, float]], None]] = []
28+
29+
30+
# Metric/Telemetry hooks can be implemented by plugin engines
31+
# to collect discrete data on how modin is performing at the
32+
# high level modin layer.
33+
def emit_metric(name: str, value: Union[int, float]) -> None:
34+
"""
35+
Emit a metric using the set of registered handlers.
36+
37+
Parameters
38+
----------
39+
name : str, required
40+
Name of the metric, in dot-format.
41+
value : int or float required
42+
Value of the metric.
43+
"""
44+
if MetricsMode.get() == "disable":
45+
return
46+
if not re.fullmatch(metric_name_pattern, name):
47+
raise KeyError(
48+
f"Metrics name is not in metric-name dot format, (eg. modin.dataframe.hist.duration ): {name}"
49+
)
50+
51+
handlers = _metric_handlers.copy()
52+
for fn in handlers:
53+
try:
54+
fn(f"modin.{name}", value)
55+
except Exception:
56+
clear_metric_handler(fn)
57+
58+
59+
def add_metric_handler(handler: Callable[[str, Union[int, float]], None]) -> None:
60+
"""
61+
Add a metric handler to Modin which can collect metrics.
62+
63+
Parameters
64+
----------
65+
handler : Callable, required
66+
"""
67+
_metric_handlers.append(handler)
68+
69+
70+
def clear_metric_handler(handler: Callable[[str, Union[int, float]], None]) -> None:
71+
"""
72+
Remove a metric handler from Modin.
73+
74+
Parameters
75+
----------
76+
handler : Callable, required
77+
"""
78+
if handler in _metric_handlers:
79+
_metric_handlers.remove(handler)

modin/tests/test_metrics.py

+105
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Licensed to Modin Development Team under one or more contributor license agreements.
2+
# See the NOTICE file distributed with this work for additional information regarding
3+
# copyright ownership. The Modin Development Team licenses this file to you under the
4+
# Apache License, Version 2.0 (the "License"); you may not use this file except in
5+
# compliance with the License. You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software distributed under
10+
# the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
11+
# ANY KIND, either express or implied. See the License for the specific language
12+
# governing permissions and limitations under the License.
13+
14+
from typing import Union
15+
16+
import pytest
17+
18+
import modin.logging
19+
import modin.pandas as pd
20+
from modin.config import MetricsMode
21+
from modin.logging.metrics import (
22+
_metric_handlers,
23+
add_metric_handler,
24+
clear_metric_handler,
25+
emit_metric,
26+
)
27+
28+
29+
class FakeTelemetryClient:
30+
31+
def __init__(self):
32+
self._metrics = {}
33+
self._metric_handler = None
34+
35+
def metric_handler_fail(self, name: str, value: Union[int, float]):
36+
raise KeyError("Poorly implemented metric handler")
37+
38+
def metric_handler_pass(self, name: str, value: Union[int, float]):
39+
self._metrics[name] = value
40+
41+
42+
@modin.logging.enable_logging
43+
def func(do_raise):
44+
if do_raise:
45+
raise ValueError()
46+
47+
48+
@pytest.fixture()
49+
def metric_client():
50+
MetricsMode.enable()
51+
client = FakeTelemetryClient()
52+
yield client
53+
clear_metric_handler(client._metric_handler)
54+
MetricsMode.disable()
55+
56+
57+
def test_metrics_api_timings(metric_client):
58+
assert len(_metric_handlers) == 0
59+
metric_client._metric_handler = metric_client.metric_handler_pass
60+
add_metric_handler(metric_client._metric_handler)
61+
assert len(_metric_handlers) == 1
62+
assert _metric_handlers[0] == metric_client._metric_handler
63+
func(do_raise=False)
64+
assert len(metric_client._metrics) == 1
65+
assert metric_client._metrics["modin.pandas-api.func"] is not None
66+
assert metric_client._metrics["modin.pandas-api.func"] > 0.0
67+
68+
69+
def test_df_metrics(metric_client):
70+
metric_client._metric_handler = metric_client.metric_handler_pass
71+
add_metric_handler(metric_client._metric_handler)
72+
df = pd.DataFrame({"a": [1, 2], "b": [3, 4]})
73+
df.sum()
74+
assert len(metric_client._metrics) == 54
75+
assert metric_client._metrics["modin.pandas-api.dataframe.sum"] is not None
76+
assert metric_client._metrics["modin.pandas-api.dataframe.sum"] > 0.0
77+
78+
79+
def test_metrics_handler_fails(metric_client):
80+
assert len(metric_client._metrics) == 0
81+
metric_client._metric_handler = metric_client.metric_handler_fail
82+
add_metric_handler(metric_client._metric_handler)
83+
assert len(_metric_handlers) == 1
84+
func(do_raise=False)
85+
assert len(_metric_handlers) == 0
86+
assert len(metric_client._metrics) == 0
87+
88+
89+
def test_emit_name_enforced():
90+
MetricsMode.enable()
91+
with pytest.raises(KeyError):
92+
emit_metric("Not::A::Valid::Metric::Name", 1.0)
93+
94+
95+
def test_metrics_can_be_opt_out(metric_client):
96+
MetricsMode.enable()
97+
assert len(metric_client._metrics) == 0
98+
metric_client._metric_handler = metric_client.metric_handler_pass
99+
add_metric_handler(metric_client._metric_handler)
100+
# If Metrics are disabled after the addition of a handler
101+
# no metrics are emitted
102+
MetricsMode.disable()
103+
assert len(_metric_handlers) == 1
104+
func(do_raise=False)
105+
assert len(metric_client._metrics) == 0

0 commit comments

Comments
 (0)