diff --git a/core/spring-boot/src/main/java/org/springframework/boot/ApplicationProperties.java b/core/spring-boot/src/main/java/org/springframework/boot/ApplicationProperties.java index 48058b7d6bb4..6d5cc91477bc 100644 --- a/core/spring-boot/src/main/java/org/springframework/boot/ApplicationProperties.java +++ b/core/spring-boot/src/main/java/org/springframework/boot/ApplicationProperties.java @@ -68,6 +68,11 @@ class ApplicationProperties { */ private boolean logStartupInfo = true; + /** + * Format used to display application startup time in logs. + */ + private StartupTimeFormat logStartupTimeFormat = StartupTimeFormat.DEFAULT; + /** * Whether the application should have a shutdown hook registered. */ @@ -139,6 +144,14 @@ void setLogStartupInfo(boolean logStartupInfo) { this.logStartupInfo = logStartupInfo; } + StartupTimeFormat getLogStartupTimeFormat() { + return this.logStartupTimeFormat; + } + + void setLogStartupTimeFormat(StartupTimeFormat logStartupTimeFormat) { + this.logStartupTimeFormat = logStartupTimeFormat; + } + boolean isRegisterShutdownHook() { return this.registerShutdownHook; } diff --git a/core/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java b/core/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java index b0e1bcf64934..3ece14ba23f4 100644 --- a/core/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java +++ b/core/spring-boot/src/main/java/org/springframework/boot/SpringApplication.java @@ -322,7 +322,8 @@ public ConfigurableApplicationContext run(String... args) { afterRefresh(context, applicationArguments); Duration timeTakenToStarted = startup.started(); if (this.properties.isLogStartupInfo()) { - new StartupInfoLogger(this.mainApplicationClass, environment).logStarted(getApplicationLog(), startup); + new StartupInfoLogger(this.mainApplicationClass, environment, this.properties.getLogStartupTimeFormat()) + .logStarted(getApplicationLog(), startup); } listeners.started(context, timeTakenToStarted); callRunners(context, applicationArguments); diff --git a/core/spring-boot/src/main/java/org/springframework/boot/StartupInfoLogger.java b/core/spring-boot/src/main/java/org/springframework/boot/StartupInfoLogger.java index df6f023a4472..ef53b081a9d5 100644 --- a/core/spring-boot/src/main/java/org/springframework/boot/StartupInfoLogger.java +++ b/core/spring-boot/src/main/java/org/springframework/boot/StartupInfoLogger.java @@ -44,9 +44,16 @@ class StartupInfoLogger { private final Environment environment; + private final StartupTimeFormat startupTimeFormat; + StartupInfoLogger(@Nullable Class sourceClass, Environment environment) { + this(sourceClass, environment, StartupTimeFormat.DEFAULT); + } + + StartupInfoLogger(@Nullable Class sourceClass, Environment environment, StartupTimeFormat startupTimeFormat) { this.sourceClass = sourceClass; this.environment = environment; + this.startupTimeFormat = startupTimeFormat; } void logStarting(Log applicationLog) { @@ -87,16 +94,18 @@ private CharSequence getStartedMessage(Startup startup) { message.append(startup.action()); appendApplicationName(message); message.append(" in "); - message.append(startup.timeTakenToStarted().toMillis() / 1000.0); - message.append(" seconds"); + message.append(formatDuration(startup.timeTakenToStarted().toMillis())); Long uptimeMs = startup.processUptime(); if (uptimeMs != null) { - double uptime = uptimeMs / 1000.0; - message.append(" (process running for ").append(uptime).append(")"); + message.append(" (process running for ").append(formatDuration(uptimeMs)).append(")"); } return message; } + private String formatDuration(long millis) { + return this.startupTimeFormat.format(millis); + } + private void appendAotMode(StringBuilder message) { append(message, "", () -> AotDetector.useGeneratedArtifacts() ? "AOT-processed" : null); } diff --git a/core/spring-boot/src/main/java/org/springframework/boot/StartupTimeFormat.java b/core/spring-boot/src/main/java/org/springframework/boot/StartupTimeFormat.java new file mode 100644 index 000000000000..35c8aedadb06 --- /dev/null +++ b/core/spring-boot/src/main/java/org/springframework/boot/StartupTimeFormat.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot; + +import java.time.Duration; + +/** + * Format styles for displaying application startup time in logs. + * + * @author Huang Xiao + * @since 3.5.0 + */ +public enum StartupTimeFormat { + + /** + * Default format displays time in seconds with millisecond precision (e.g., "3.456 + * seconds"). This maintains backward compatibility with existing log parsing tools. + */ + DEFAULT { + + @Override + public String format(long millis) { + return String.format("%.3f seconds", millis / 1000.0); + } + + }, + + /** + * Human format displays time in a more intuitive way using appropriate units (e.g., + * "1 minute 30 seconds" or "1 hour 15 minutes"). Times under 60 seconds still use the + * default format for consistency. + */ + HUMAN { + + @Override + public String format(long millis) { + Duration duration = Duration.ofMillis(millis); + long seconds = duration.getSeconds(); + if (seconds < 60) { + return String.format("%.3f seconds", millis / 1000.0); + } + long hours = duration.toHours(); + int minutes = duration.toMinutesPart(); + int secs = duration.toSecondsPart(); + if (hours > 0) { + return String.format("%d hour%s %d minute%s", hours, (hours != 1) ? "s" : "", minutes, + (minutes != 1) ? "s" : ""); + } + return String.format("%d minute%s %d second%s", minutes, (minutes != 1) ? "s" : "", secs, + (secs != 1) ? "s" : ""); + } + + }; + + /** + * Format the given duration in milliseconds according to this format style. + * @param millis the duration in milliseconds + * @return the formatted string + */ + public abstract String format(long millis); + +} diff --git a/core/spring-boot/src/test/java/org/springframework/boot/StartupInfoLoggerTests.java b/core/spring-boot/src/test/java/org/springframework/boot/StartupInfoLoggerTests.java index c8bca9f89375..778e71958f91 100644 --- a/core/spring-boot/src/test/java/org/springframework/boot/StartupInfoLoggerTests.java +++ b/core/spring-boot/src/test/java/org/springframework/boot/StartupInfoLoggerTests.java @@ -109,7 +109,7 @@ void startedFormat() { new StartupInfoLogger(getClass(), this.environment).logStarted(this.log, new TestStartup(1345L, "Started")); then(this.log).should() .info(assertArg((message) -> assertThat(message.toString()).matches("Started " + getClass().getSimpleName() - + " in \\d+\\.\\d{1,3} seconds \\(process running for 1.345\\)"))); + + " in \\d+\\.\\d{1,3} seconds \\(process running for 1\\.345 seconds\\)"))); } @Test @@ -130,17 +130,79 @@ void restoredFormat() { .matches("Restored " + getClass().getSimpleName() + " in \\d+\\.\\d{1,3} seconds"))); } + @Test + void startedFormatWithHumanMinutes() { + given(this.log.isInfoEnabled()).willReturn(true); + new StartupInfoLogger(getClass(), this.environment, StartupTimeFormat.HUMAN).logStarted(this.log, + new TestStartup(90000L, "Started", 90000L)); + then(this.log).should() + .info(assertArg( + (message) -> assertThat(message.toString()).isEqualTo("Started " + getClass().getSimpleName() + + " in 1 minute 30 seconds (process running for 1 minute 30 seconds)"))); + } + + @Test + void startedFormatWithHumanHours() { + given(this.log.isInfoEnabled()).willReturn(true); + new StartupInfoLogger(getClass(), this.environment, StartupTimeFormat.HUMAN).logStarted(this.log, + new TestStartup(4500000L, "Started", 4500000L)); + then(this.log).should() + .info(assertArg((message) -> assertThat(message.toString()).isEqualTo("Started " + + getClass().getSimpleName() + " in 1 hour 15 minutes (process running for 1 hour 15 minutes)"))); + } + + @Test + void startedFormatWithHumanSingularUnits() { + given(this.log.isInfoEnabled()).willReturn(true); + new StartupInfoLogger(getClass(), this.environment, StartupTimeFormat.HUMAN).logStarted(this.log, + new TestStartup(61000L, "Started", 61000L)); + then(this.log).should() + .info(assertArg((message) -> assertThat(message.toString()).isEqualTo("Started " + + getClass().getSimpleName() + " in 1 minute 1 second (process running for 1 minute 1 second)"))); + } + + @Test + void startedFormatWithHumanZeroSeconds() { + given(this.log.isInfoEnabled()).willReturn(true); + new StartupInfoLogger(getClass(), this.environment, StartupTimeFormat.HUMAN).logStarted(this.log, + new TestStartup(300000L, "Started", 300000L)); + then(this.log).should() + .info(assertArg( + (message) -> assertThat(message.toString()).isEqualTo("Started " + getClass().getSimpleName() + + " in 5 minutes 0 seconds (process running for 5 minutes 0 seconds)"))); + } + + @Test + void startedFormatWithDefaultDecimalFormat() { + given(this.log.isInfoEnabled()).willReturn(true); + new StartupInfoLogger(getClass(), this.environment).logStarted(this.log, + new TestStartup(90000L, "Started", 90000L)); + then(this.log).should() + .info(assertArg((message) -> assertThat(message.toString()).matches("Started " + getClass().getSimpleName() + + " in \\d+\\.\\d{1,3} seconds \\(process running for \\d+\\.\\d{1,3} seconds\\)"))); + } + static class TestStartup extends Startup { - private final long startTime = System.currentTimeMillis(); + private long startTime; private final @Nullable Long uptime; private final String action; TestStartup(@Nullable Long uptime, String action) { + this(uptime, action, null); + } + + TestStartup(@Nullable Long uptime, String action, @Nullable Long timeTaken) { this.uptime = uptime; this.action = action; + if (timeTaken != null) { + this.startTime = System.currentTimeMillis() - timeTaken; + } + else { + this.startTime = System.currentTimeMillis(); + } started(); }