Repository: pallets/werkzeug

Issue:
Elapsed and timestamp values are not available to filename_format function
When the `ProfilerMiddleware` `filename_format` argument is a callable, the callable will only receive the WSGI environ variable. However, the default format string can use the `elapsed` and `time` values. This means that one using a format function cannot recreate a similar output pattern. In my case, I'd like to use both, but format the timestamp differently.


Example: I'd like a filename of the form `2023-08-21:14:05:05.POST.myurl.13ms.prof`. I can get the timestamp by doing my own `datetime.now()` call, but the elapsed time is not available.

This problem is solvable by subclassing `ProfilerMiddleware` or writing ones own profiling middleware, but this seems like a useful thing to have in the core library so library users don't need to duplicate the `__call__()` code in their own projects.

I can submit a PR if this change is welcome.



Discussion:

Resolving patch:
diff --git a/src/werkzeug/middleware/profiler.py b/src/werkzeug/middleware/profiler.py
--- a/src/werkzeug/middleware/profiler.py
+++ b/src/werkzeug/middleware/profiler.py
@@ -44,11 +44,16 @@ class ProfilerMiddleware:
 
     -   ``{method}`` - The request method; GET, POST, etc.
     -   ``{path}`` - The request path or 'root' should one not exist.
-    -   ``{elapsed}`` - The elapsed time of the request.
+    -   ``{elapsed}`` - The elapsed time of the request in milliseconds.
     -   ``{time}`` - The time of the request.
 
-    If it is a callable, it will be called with the WSGI ``environ``
-    dict and should return a filename.
+    If it is a callable, it will be called with the WSGI ``environ`` and
+    be expected to return a filename string. The ``environ`` dictionary
+    will also have the ``"werkzeug.profiler"`` key populated with a
+    dictionary containing the following fields (more may be added in the
+    future):
+    -   ``{elapsed}`` - The elapsed time of the request in milliseconds.
+    -   ``{time}`` - The time of the request.
 
     :param app: The WSGI application to wrap.
     :param stream: Write stats to this stream. Disable with ``None``.
@@ -65,6 +70,10 @@ class ProfilerMiddleware:
         from werkzeug.middleware.profiler import ProfilerMiddleware
         app = ProfilerMiddleware(app)
 
+    .. versionchanged:: 3.0
+        Added the ``"werkzeug.profiler"`` key to the ``filename_format(environ)``
+        parameter with the  ``elapsed`` and ``time`` fields.
+
     .. versionchanged:: 0.15
         Stats are written even if ``profile_dir`` is given, and can be
         disable by passing ``stream=None``.
@@ -118,6 +127,10 @@ def runapp() -> None:
 
         if self._profile_dir is not None:
             if callable(self._filename_format):
+                environ["werkzeug.profiler"] = {
+                    "elapsed": elapsed * 1000.0,
+                    "time": time.time(),
+                }
                 filename = self._filename_format(environ)
             else:
                 filename = self._filename_format.format(


Test patch:
diff --git a/tests/middleware/test_profiler.py b/tests/middleware/test_profiler.py
new file mode 100644
--- /dev/null
+++ b/tests/middleware/test_profiler.py
@@ -0,0 +1,50 @@
+import datetime
+import os
+from unittest.mock import ANY
+from unittest.mock import MagicMock
+from unittest.mock import patch
+
+from werkzeug.middleware.profiler import Profile
+from werkzeug.middleware.profiler import ProfilerMiddleware
+from werkzeug.test import Client
+
+
+def dummy_application(environ, start_response):
+    start_response("200 OK", [("Content-Type", "text/plain")])
+    return [b"Foo"]
+
+
+def test_filename_format_function():
+    # This should be called once with the generated file name
+    mock_capture_name = MagicMock()
+
+    def filename_format(env):
+        now = datetime.datetime.fromtimestamp(env["werkzeug.profiler"]["time"])
+        timestamp = now.strftime("%Y-%m-%d:%H:%M:%S")
+        path = (
+            "_".join(token for token in env["PATH_INFO"].split("/") if token) or "ROOT"
+        )
+        elapsed = env["werkzeug.profiler"]["elapsed"]
+        name = f"{timestamp}.{env['REQUEST_METHOD']}.{path}.{elapsed:.0f}ms.prof"
+        mock_capture_name(name=name)
+        return name
+
+    client = Client(
+        ProfilerMiddleware(
+            dummy_application,
+            stream=None,
+            profile_dir="profiles",
+            filename_format=filename_format,
+        )
+    )
+
+    # Replace the Profile class with a function that simulates an __init__()
+    # call and returns our mock instance.
+    mock_profile = MagicMock(wraps=Profile())
+    mock_profile.dump_stats = MagicMock()
+    with patch("werkzeug.middleware.profiler.Profile", lambda: mock_profile):
+        client.get("/foo/bar")
+
+        mock_capture_name.assert_called_once_with(name=ANY)
+        name = mock_capture_name.mock_calls[0].kwargs["name"]
+        mock_profile.dump_stats.assert_called_once_with(os.path.join("profiles", name))