This is an automated email from the ASF dual-hosted git repository.

uranusjr pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 7de050ceeb Add %z for %(asctime)s to fix timezone for logs on UI 
(#24373)
7de050ceeb is described below

commit 7de050ceeb381fb7959b65acd7008e85b430c46f
Author: Ko HanJong <[email protected]>
AuthorDate: Fri Jun 24 22:17:24 2022 +0900

    Add %z for %(asctime)s to fix timezone for logs on UI (#24373)
---
 airflow/config_templates/airflow_local_settings.py | 12 +++++--
 airflow/config_templates/config.yml                |  6 ++++
 airflow/config_templates/default_airflow.cfg       |  1 +
 airflow/utils/log/colored_log.py                   |  4 +++
 airflow/utils/log/timezone_aware.py                | 39 ++++++++++++++++++++++
 .../details/content/taskInstance/Logs/utils.js     | 34 +++++++++++++++----
 airflow/www/static/js/ti_log.js                    | 21 ++++++++++--
 newsfragments/24373.significant.rst                | 23 +++++++++++++
 8 files changed, 129 insertions(+), 11 deletions(-)

diff --git a/airflow/config_templates/airflow_local_settings.py 
b/airflow/config_templates/airflow_local_settings.py
index 6684fd18e5..141f52939e 100644
--- a/airflow/config_templates/airflow_local_settings.py
+++ b/airflow/config_templates/airflow_local_settings.py
@@ -31,13 +31,16 @@ from airflow.exceptions import AirflowException
 # settings.py and cli.py. Please see AIRFLOW-1455.
 LOG_LEVEL: str = conf.get_mandatory_value('logging', 'LOGGING_LEVEL').upper()
 
-
 # Flask appbuilder's info level log is very verbose,
 # so it's set to 'WARN' by default.
 FAB_LOG_LEVEL: str = conf.get_mandatory_value('logging', 
'FAB_LOGGING_LEVEL').upper()
 
 LOG_FORMAT: str = conf.get_mandatory_value('logging', 'LOG_FORMAT')
 
+LOG_FORMATTER_CLASS: str = conf.get_mandatory_value(
+    'logging', 'LOG_FORMATTER_CLASS', 
fallback='airflow.utils.log.timezone_aware.TimezoneAware'
+)
+
 COLORED_LOG_FORMAT: str = conf.get_mandatory_value('logging', 
'COLORED_LOG_FORMAT')
 
 COLORED_LOG: bool = conf.getboolean('logging', 'COLORED_CONSOLE_LOG')
@@ -60,10 +63,13 @@ DEFAULT_LOGGING_CONFIG: Dict[str, Any] = {
     'version': 1,
     'disable_existing_loggers': False,
     'formatters': {
-        'airflow': {'format': LOG_FORMAT},
+        'airflow': {
+            'format': LOG_FORMAT,
+            'class': LOG_FORMATTER_CLASS,
+        },
         'airflow_coloured': {
             'format': COLORED_LOG_FORMAT if COLORED_LOG else LOG_FORMAT,
-            'class': COLORED_FORMATTER_CLASS if COLORED_LOG else 
'logging.Formatter',
+            'class': COLORED_FORMATTER_CLASS if COLORED_LOG else 
LOG_FORMATTER_CLASS,
         },
     },
     'filters': {
diff --git a/airflow/config_templates/config.yml 
b/airflow/config_templates/config.yml
index 1225c73a97..f7c48c1fa1 100644
--- a/airflow/config_templates/config.yml
+++ b/airflow/config_templates/config.yml
@@ -633,6 +633,12 @@
       type: string
       example: ~
       default: "%%(asctime)s %%(levelname)s - %%(message)s"
+    - name: log_formatter_class
+      description: ~
+      version_added: 2.3.3
+      type: string
+      example: ~
+      default: "airflow.utils.log.timezone_aware.TimezoneAware"
     - name: task_log_prefix_template
       description: |
         Specify prefix pattern like mentioned below with stream handler 
TaskHandlerWithCustomFormatter
diff --git a/airflow/config_templates/default_airflow.cfg 
b/airflow/config_templates/default_airflow.cfg
index 214890947b..d990010790 100644
--- a/airflow/config_templates/default_airflow.cfg
+++ b/airflow/config_templates/default_airflow.cfg
@@ -349,6 +349,7 @@ colored_formatter_class = 
airflow.utils.log.colored_log.CustomTTYColoredFormatte
 # Format of Log line
 log_format = [%%(asctime)s] {{%%(filename)s:%%(lineno)d}} %%(levelname)s - 
%%(message)s
 simple_log_format = %%(asctime)s %%(levelname)s - %%(message)s
+log_formatter_class = airflow.utils.log.timezone_aware.TimezoneAware
 
 # Specify prefix pattern like mentioned below with stream handler 
TaskHandlerWithCustomFormatter
 # Example: task_log_prefix_template = 
{{ti.dag_id}}-{{ti.task_id}}-{{execution_date}}-{{try_number}}
diff --git a/airflow/utils/log/colored_log.py b/airflow/utils/log/colored_log.py
index d1adb0859b..f77af382b6 100644
--- a/airflow/utils/log/colored_log.py
+++ b/airflow/utils/log/colored_log.py
@@ -43,6 +43,10 @@ class CustomTTYColoredFormatter(TTYColoredFormatter):
     traceback.
     """
 
+    # copy of airflow.utils.log.timezone_aware.TimezoneAware
+    default_time_format = '%Y-%m-%d %H:%M:%S%z'
+    default_msec_format = '%s %03dms'
+
     def __init__(self, *args, **kwargs):
         kwargs["stream"] = sys.stdout or kwargs.get("stream")
         kwargs["log_colors"] = DEFAULT_COLORS
diff --git a/airflow/utils/log/timezone_aware.py 
b/airflow/utils/log/timezone_aware.py
new file mode 100644
index 0000000000..a25ac49ef8
--- /dev/null
+++ b/airflow/utils/log/timezone_aware.py
@@ -0,0 +1,39 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you 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
+#
+#   http://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.
+import logging
+
+
+class TimezoneAware(logging.Formatter):
+    """
+    Override `default_time_format` and `default_msec_format` to specify utc 
offset.
+
+    utc offset is the matter, without it,  time conversion could be wrong. 
With this Formatter, `%(asctime)s`
+    will be formatted containing utc offset. (e.g. 2022-06-12 13:00:00+0000 
123ms)
+
+    moments.js couldn't parse milliseconds comes after utc offset, so it would 
be ideal `%(asctime)s`
+    formatted with millisecond comes before utc offset in th first place. (e.g 
2022-06-12 13:00:00.123+0000)
+    But python standard lib doesn't support format like that.
+
+    Omitting milliseconds is possible by assigning `default_msec_format` to 
`None`. But this requires
+    python3.9 or higher, so we can't omit milliseconds until dropping support 
3.8 or under.
+
+    Therefore, to use in moments.js, formatted `%(asctime)s` has to be 
re-formatted by javascript side.
+    """
+
+    default_time_format = '%Y-%m-%d %H:%M:%S%z'
+    default_msec_format = '%s %03dms'
diff --git 
a/airflow/www/static/js/grid/details/content/taskInstance/Logs/utils.js 
b/airflow/www/static/js/grid/details/content/taskInstance/Logs/utils.js
index 0b6d3de4dd..0678f520a2 100644
--- a/airflow/www/static/js/grid/details/content/taskInstance/Logs/utils.js
+++ b/airflow/www/static/js/grid/details/content/taskInstance/Logs/utils.js
@@ -48,17 +48,39 @@ export const parseLogs = (data, timezone, logLevelFilter, 
fileSourceFilter) => {
     }
 
     const regExp = /\[(.*?)\] \{(.*?)\}/;
+    // e.g) '2022-06-15 10:30:06,020' or '2022-06-15 10:30:06+0900'
+    const dateRegex = /(\d{4}[./-]\d{2}[./-]\d{2} 
\d{2}:\d{2}:\d{2})((,\d{3})|([+-]\d{4} \d{3}ms))/;
+    // above regex is a kind of duplication of 'dateRegex'
+    // in airflow/www/static/js/tl_log.js
     const matches = line.match(regExp);
     let logGroup = '';
     if (matches) {
-      // Replace UTC with the local timezone.
+      // Replace system timezone with user selected timezone.
       const dateTime = matches[1];
-      [logGroup] = matches[2].split(':');
-      if (dateTime && timezone) {
-        const localDateTime = 
moment.utc(dateTime).tz(timezone).format(defaultFormatWithTZ);
-        parsedLine = line.replace(dateTime, localDateTime);
-      }
 
+      // e.g) '2022-06-15 10:30:06,020' or '2022-06-15 10:30:06+0900 123ms'
+      const dateMatches = dateTime?.match(dateRegex);
+      if (dateMatches) {
+        const [date, msecOrUTCOffset] = [dateMatches[1], dateMatches[2]];
+        if (msecOrUTCOffset.startsWith(',')) { // e.g) date='2022-06-15 
10:30:06', msecOrUTCOffset=',020'
+          // for backward compatibility. (before 2.3.3)
+          // keep previous behavior if utcoffset not found. (consider it UTC)
+          //
+          if (dateTime && timezone) { // dateTime === fullMatch
+            const localDateTime = 
moment.utc(dateTime).tz(timezone).format(defaultFormatWithTZ);
+            parsedLine = line.replace(dateTime, localDateTime);
+          }
+        } else {
+          // e.g) date='2022-06-15 10:30:06', msecOrUTCOffset='+0900 123ms'
+          // (formatted by airflow.utils.log.timezone_aware.TimezoneAware) 
(since 2.3.3)
+          const [utcoffset, threeDigitMs] = msecOrUTCOffset.split(' ');
+          const msec = threeDigitMs.replace(/\D+/g, ''); // drop 'ms'
+          // e.g) datetime='2022-06-15 10:30:06.123+0900'
+          const localDateTime = 
moment(`${date}.${msec}${utcoffset}`).tz(timezone).format(defaultFormatWithTZ);
+          parsedLine = line.replace(dateTime, localDateTime);
+        }
+      }
+      [logGroup] = matches[2].split(':');
       fileSources.add(logGroup);
     }
     if (!fileSourceFilter || fileSourceFilter === logGroup) {
diff --git a/airflow/www/static/js/ti_log.js b/airflow/www/static/js/ti_log.js
index 1bf6b501a6..b6faa129b4 100644
--- a/airflow/www/static/js/ti_log.js
+++ b/airflow/www/static/js/ti_log.js
@@ -102,7 +102,9 @@ function autoTailingLog(tryNumber, metadata = null, 
autoTailing = false) {
 
       // Detect urls and log timestamps
       const urlRegex = 
/http(s)?:\/\/[\w.-]+(\.?:[\w.-]+)*([/?#][\w\-._~:/?#[\]@!$&'()*+,;=.%]+)?/g;
-      const dateRegex = /\d{4}[./-]\d{2}[./-]\d{2} \d{2}:\d{2}:\d{2},\d{3}/g;
+      const dateRegex = /(\d{4}[./-]\d{2}[./-]\d{2} 
\d{2}:\d{2}:\d{2})((,\d{3})|([+-]\d{4} \d{3}ms))/g;
+      // above regex is a kind of duplication of 'dateRegex'
+      // in 
airflow/www/static/js/grid/details/content/taskinstance/Logs/utils.js
 
       res.message.forEach((item) => {
         const logBlockElementId = `try-${tryNumber}-${item[0]}`;
@@ -120,7 +122,22 @@ function autoTailingLog(tryNumber, metadata = null, 
autoTailing = false) {
         const escapedMessage = escapeHtml(item[1]);
         const linkifiedMessage = escapedMessage
           .replace(urlRegex, (url) => `<a href="${url}" 
target="_blank">${url}</a>`)
-          .replaceAll(dateRegex, (date) => `<time datetime="${date}+00:00" 
data-with-tz="true">${formatDateTime(`${date}+00:00`)}</time>`);
+          .replaceAll(dateRegex, (dateMatches, date, msecOrUTCOffset) => {
+            // e.g) '2022-06-15 10:30:06,020' or '2022-06-15 10:30:06+0900 
123ms'
+            if (msecOrUTCOffset.startsWith(',')) { // e.g) date='2022-06-15 
10:30:06', msecOrUTCOffset=',020'
+              // for backward compatibility. (before 2.3.3)
+              // keep previous behavior if utcoffset not found.
+              //
+              return `<time datetime="${dateMatches}+00:00" 
data-with-tz="true">${formatDateTime(`${dateMatches}+00:00`)}</time>`;
+            }
+            // e.g) date='2022-06-15 10:30:06', msecOrUTCOffset='+0900 123ms'
+            // (formatted by airflow.utils.log.timezone_aware.TimezoneAware) 
(since 2.3.3)
+            const [utcoffset, threeDigitMs] = msecOrUTCOffset.split(' ');
+            const msec = threeDigitMs.replace(/\D+/g, ''); // drop 'ms'
+            const dateTime = `${date}.${msec}${utcoffset}`; // e.g) 
datetime='2022-06-15 10:30:06.123+0900'
+            //
+            return `<time datetime="${dateTime}" 
data-with-tz="true">${formatDateTime(`${dateTime}`)}</time>`;
+          });
         logBlock.innerHTML += `${linkifiedMessage}\n`;
       });
 
diff --git a/newsfragments/24373.significant.rst 
b/newsfragments/24373.significant.rst
new file mode 100644
index 0000000000..3c7cb9becd
--- /dev/null
+++ b/newsfragments/24373.significant.rst
@@ -0,0 +1,23 @@
+Added new config ``[logging]log_formatter_class`` to fix timezone display for 
logs on UI
+
+If you are using a custom Formatter subclass in your 
``[logging]logging_config_class``, please inherit from 
``airflow.utils.log.timezone_aware.TimezoneAware`` instead of 
``logging.Formatter``.
+For example, in your ``custom_config.py``:
+
+.. code-block:: python
+
+   from airflow.utils.log.timezone_aware import TimezoneAware
+
+   # before
+   class YourCustomFormatter(logging.Formatter):
+       ...
+
+
+   # after
+   class YourCustomFormatter(TimezoneAware):
+       ...
+
+
+   AIRFLOW_FORMATTER = LOGGING_CONFIG["formatters"]["airflow"]
+   AIRFLOW_FORMATTER["class"] = 
"somewhere.your.custom_config.YourCustomFormatter"
+   # or use TimezoneAware class directly. If you don't have custom Formatter.
+   AIRFLOW_FORMATTER["class"] = 
"airflow.utils.log.timezone_aware.TimezoneAware"

Reply via email to