Skip to content

Commit b1fcf28

Browse files
freidercursoragent
andauthored
Modal run timestamps (#3882)
* Add --timestamps flag to modal run, serve and deploy This adds support for the same --timestamps flag that exists in `modal app logs` to the `modal run` command. When enabled, each log line from stdout/stderr will be prefixed with a timestamp. --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent ba9831b commit b1fcf28

4 files changed

Lines changed: 53 additions & 10 deletions

File tree

modal/_output.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,9 @@ def get(cls) -> OutputManager | None:
169169

170170
@classmethod
171171
@contextlib.contextmanager
172-
def enable_output(cls, show_progress: bool = True) -> Generator[None]:
172+
def enable_output(cls, show_progress: bool = True, show_timestamps: bool = False) -> Generator[None]:
173173
if show_progress:
174-
cls._instance = OutputManager()
174+
cls._instance = OutputManager(show_timestamps=show_timestamps)
175175
try:
176176
yield
177177
finally:

modal/cli/run.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,8 @@ def f(ctx, **kwargs):
185185
_validate_interactive_quiet_params(ctx)
186186

187187
show_progress: bool = ctx.obj["show_progress"]
188-
with enable_output(show_progress):
188+
show_timestamps: bool = ctx.obj["show_timestamps"]
189+
with enable_output(show_progress, show_timestamps=show_timestamps):
189190
with run_app(
190191
app,
191192
detach=ctx.obj["detach"],
@@ -314,7 +315,8 @@ def f(ctx, *args, **kwargs):
314315
_validate_interactive_quiet_params(ctx)
315316

316317
show_progress: bool = ctx.obj["show_progress"]
317-
with enable_output(show_progress):
318+
show_timestamps: bool = ctx.obj["show_timestamps"]
319+
with enable_output(show_progress, show_timestamps=show_timestamps):
318320
with run_app(
319321
app,
320322
detach=ctx.obj["detach"],
@@ -404,8 +406,9 @@ def get_command(self, ctx, func_ref):
404406
@click.option("-i", "--interactive", is_flag=True, help="Run the app in interactive mode.")
405407
@click.option("-e", "--env", help=ENV_OPTION_HELP, default=None)
406408
@click.option("-m", is_flag=True, help="Interpret argument as a Python module path instead of a file/script path")
409+
@click.option("--timestamps", is_flag=True, help="Show timestamps for each log line.")
407410
@click.pass_context
408-
def run(ctx, write_result, detach, quiet, interactive, env, m):
411+
def run(ctx, write_result, detach, quiet, interactive, env, m, timestamps):
409412
"""Run a Modal function or local entrypoint.
410413
411414
`FUNC_REF` should be of the format `{file or module}::{function name}`.
@@ -442,6 +445,7 @@ def run(ctx, write_result, detach, quiet, interactive, env, m):
442445
ctx.obj["detach"] = detach # if subcommand would be a click command...
443446
ctx.obj["show_progress"] = False if quiet else True
444447
ctx.obj["interactive"] = interactive
448+
ctx.obj["show_timestamps"] = timestamps
445449

446450

447451
def deploy(
@@ -453,6 +457,7 @@ def deploy(
453457
use_module_mode: bool = typer.Option(
454458
False, "-m", help="Interpret argument as a Python module path instead of a file/script path"
455459
),
460+
timestamps: bool = typer.Option(False, "--timestamps", help="Show timestamps for each log line."),
456461
):
457462
"""Deploy a Modal application.
458463
@@ -477,11 +482,11 @@ def deploy(
477482
"modal deploy ... --name=some-name"
478483
)
479484

480-
with enable_output():
485+
with enable_output(show_timestamps=timestamps):
481486
res = deploy_app(app, name=name, environment_name=env or "", tag=tag)
482487

483488
if stream_logs:
484-
stream_app_logs(app_id=res.app_id, app_logs_url=res.app_logs_url)
489+
stream_app_logs(app_id=res.app_id, app_logs_url=res.app_logs_url, show_timestamps=timestamps)
485490

486491

487492
def serve(
@@ -491,6 +496,7 @@ def serve(
491496
use_module_mode: bool = typer.Option(
492497
False, "-m", help="Interpret argument as a Python module path instead of a file/script path"
493498
),
499+
timestamps: bool = typer.Option(False, "--timestamps", help="Show timestamps for each log line."),
494500
):
495501
"""Run a web endpoint(s) associated with a Modal app and hot-reload code.
496502
@@ -512,7 +518,7 @@ def serve(
512518
if app.description is None:
513519
app.set_description(_get_clean_app_description(app_ref))
514520

515-
with enable_output():
521+
with enable_output(show_timestamps=timestamps):
516522
with serve_app(app, import_ref, environment_name=env):
517523
if timeout is None:
518524
timeout = config["serve_timeout"]

modal/output.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020

2121
@contextlib.contextmanager
22-
def enable_output(show_progress: bool = True) -> Generator[None, None, None]:
22+
def enable_output(show_progress: bool = True, show_timestamps: bool = False) -> Generator[None, None, None]:
2323
"""Context manager that enable output when using the Python SDK.
2424
2525
This will print to stdout and stderr things such as
@@ -47,7 +47,7 @@ def enable_output(show_progress: bool = True) -> Generator[None, None, None]:
4747
global OUTPUT_ENABLED
4848

4949
try:
50-
with OutputManager.enable_output(show_progress):
50+
with OutputManager.enable_output(show_progress, show_timestamps=show_timestamps):
5151
OUTPUT_ENABLED = True
5252
yield
5353
finally:

test/cli_test.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,43 @@ async def app_done(self, stream):
558558
)
559559

560560

561+
def test_run_timestamps(servicer, server_url_env, set_env_client, test_dir, monkeypatch):
562+
from datetime import timezone
563+
564+
# Use a known timestamp (2025-01-15 12:00:45 UTC)
565+
known_timestamp = 1736942445.0
566+
567+
# Mock locale_tz to return UTC for predictable timestamp formatting
568+
monkeypatch.setattr("modal._utils.time_utils.locale_tz", lambda: timezone.utc)
569+
570+
async def app_logs_with_timestamp(self, stream):
571+
await stream.recv_message()
572+
log = api_pb2.TaskLogs(
573+
data="test log message\n",
574+
file_descriptor=api_pb2.FILE_DESCRIPTOR_STDOUT,
575+
timestamp=known_timestamp,
576+
)
577+
await stream.send_message(api_pb2.TaskLogsBatch(entry_id="1", items=[log]))
578+
await stream.send_message(api_pb2.TaskLogsBatch(app_done=True))
579+
580+
with servicer.intercept() as ctx:
581+
ctx.set_responder("AppGetLogs", app_logs_with_timestamp)
582+
583+
app_file = test_dir / "supports" / "app_run_tests" / "default_app.py"
584+
585+
# Test without --timestamps flag - should not include timestamp
586+
res = run_cli_command(["run", app_file.as_posix()])
587+
assert "test log message" in res.stdout
588+
# The timestamp string format is "YYYY-MM-DD HH:MM:SS+TZ" - check that it's NOT there
589+
assert "2025-01-15 12:00:45" not in res.stdout
590+
591+
# Test with --timestamps flag - should include timestamp prefix
592+
res = run_cli_command(["run", "--timestamps", app_file.as_posix()])
593+
# Check for the full formatted line: "YYYY-MM-DD HH:MM:SS+00:00 <message>"
594+
expected_line = "2025-01-15 12:00:45+00:00 test log message"
595+
assert expected_line in res.stdout
596+
597+
561598
def test_app_stop(servicer, mock_dir, set_env_client):
562599
with mock_dir({"myapp.py": dummy_app_file, "other_module.py": dummy_other_module_file}):
563600
# Deploy as a module

0 commit comments

Comments
 (0)