Skip to content

Commit

Permalink
Add support to format log streaming of structured JSON output (#3408)
Browse files Browse the repository at this point in the history
* Add support to format log streaming of structured JSON output

* fix: default logging style

* Add support to output Spring Boot flavored logger name

* fix style and linter issue in new code

* fix lint errors and suppress format error logging in common state

* adjust the version in setup.py

* refactor: use concrete user error for invalid argument
  • Loading branch information
allxiao authored May 26, 2021
1 parent 90675e0 commit 6357f87
Show file tree
Hide file tree
Showing 4 changed files with 135 additions and 18 deletions.
5 changes: 5 additions & 0 deletions src/spring-cloud/HISTORY.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
Release History
===============

2.4.0
-----
* Add support to format log streaming of structured JSON output

2.3.1
-----
* Fix disable-ssl in redis binding.
Expand Down
16 changes: 8 additions & 8 deletions src/spring-cloud/azext_spring_cloud/_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,23 +104,23 @@ def load_arguments(self, _):
c.argument('scope', help="The scope the managed identity has access to")
c.argument('role', help="Role name or id the managed identity will be assigned")

with self.argument_context('spring-cloud app logs') as c:
def prepare_logs_argument(c):
'''`app log tail` is deprecated. `app logs` is the new choice. They share the same command processor.'''
c.argument('instance', options_list=['--instance', '-i'], help='Name of an existing instance of the deployment.')
c.argument('lines', type=int, help='Number of lines to show. Maximum is 10000', validator=validate_log_lines)
c.argument('follow', options_list=['--follow ', '-f'], help='Specify if the logs should be streamed.', action='store_true')
c.argument('since', help='Only return logs newer than a relative duration like 5s, 2m, or 1h. Maximum is 1h', validator=validate_log_since)
c.argument('limit', type=int, help='Maximum kilobytes of logs to return. Ceiling number is 2048.', validator=validate_log_limit)
c.argument('deployment', options_list=[
'--deployment', '-d'], help='Name of an existing deployment of the app. Default to the production deployment if not specified.', validator=validate_deployment_name)
c.argument('format_json', nargs='?', const='{timestamp} {level:>5} [{thread:>15.15}] {logger{39}:<40.40}: {message}\n{stackTrace}',
help='Format JSON logs if structured log is enabled')

with self.argument_context('spring-cloud app logs') as c:
prepare_logs_argument(c)

with self.argument_context('spring-cloud app log tail') as c:
c.argument('instance', options_list=['--instance', '-i'], help='Name of an existing instance of the deployment.')
c.argument('lines', type=int, help='Number of lines to show. Maximum is 10000', validator=validate_log_lines)
c.argument('follow', options_list=['--follow ', '-f'], help='Specify if the logs should be streamed.', action='store_true')
c.argument('since', help='Only return logs newer than a relative duration like 5s, 2m, or 1h. Maximum is 1h', validator=validate_log_since)
c.argument('limit', type=int, help='Maximum kilobytes of logs to return. Ceiling number is 2048.', validator=validate_log_limit)
c.argument('deployment', options_list=[
'--deployment', '-d'], help='Name of an existing deployment of the app. Default to the production deployment if not specified.', validator=validate_deployment_name)
prepare_logs_argument(c)

with self.argument_context('spring-cloud app set-deployment') as c:
c.argument('deployment', options_list=[
Expand Down
130 changes: 121 additions & 9 deletions src/spring-cloud/azext_spring_cloud/custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@
from .vendored_sdks.appplatform.v2020_07_01 import models
from .vendored_sdks.appplatform.v2020_11_01_preview import models as models_20201101preview
from .vendored_sdks.appplatform.v2020_07_01.models import _app_platform_management_client_enums as AppPlatformEnums
from .vendored_sdks.appplatform.v2020_11_01_preview import AppPlatformManagementClient as AppPlatformManagementClient_20201101preview
from .vendored_sdks.appplatform.v2020_11_01_preview import (
AppPlatformManagementClient as AppPlatformManagementClient_20201101preview
)
from knack.log import get_logger
from .azure_storage_file import FileService
from azure.cli.core.azclierror import InvalidArgumentValueError
from azure.cli.core.commands.client_factory import get_mgmt_service_client
from azure.cli.core.util import sdk_no_wait
from azure.cli.core.profiles import ResourceType, get_sdk
Expand All @@ -32,6 +35,8 @@
from threading import Thread
from threading import Timer
import sys
import json
from collections import defaultdict

logger = get_logger(__name__)
DEFAULT_DEPLOYMENT_NAME = "default"
Expand Down Expand Up @@ -530,7 +535,8 @@ def app_get_build_log(cmd, client, resource_group, service, name, deployment=Non
return stream_logs(client.deployments, resource_group, service, name, deployment)


def app_tail_log(cmd, client, resource_group, service, name, deployment=None, instance=None, follow=False, lines=50, since=None, limit=2048):
def app_tail_log(cmd, client, resource_group, service, name,
deployment=None, instance=None, follow=False, lines=50, since=None, limit=2048, format_json=None):
if not instance:
if deployment is None:
deployment = client.apps.get(
Expand Down Expand Up @@ -574,7 +580,7 @@ def app_tail_log(cmd, client, resource_group, service, name, deployment=None, in
exceptions = []
streaming_url += "?{}".format(parse.urlencode(params)) if params else ""
t = Thread(target=_get_app_log, args=(
streaming_url, "primary", primary_key, exceptions))
streaming_url, "primary", primary_key, format_json, exceptions))
t.daemon = True
t.start()

Expand Down Expand Up @@ -1344,18 +1350,124 @@ def get_logs_loop():
resource_group, service, app, name, properties=properties, sku=sku)


def _get_app_log(url, user_name, password, exceptions):
# pylint: disable=bare-except, too-many-statements
def _get_app_log(url, user_name, password, format_json, exceptions):
logger_seg_regex = re.compile(r'([^\.])[^\.]+\.')

def build_log_shortener(length):
if length <= 0:
raise InvalidArgumentValueError('Logger length in `logger{length}` should be positive')

def shortener(record):
'''
Try shorten the logger property to the specified length before feeding it to the formatter.
'''
logger_name = record.get('logger', None)
if logger_name is None:
return record

# first, try to shorten the package name to one letter, e.g.,
# org.springframework.cloud.netflix.eureka.config.DiscoveryClientOptionalArgsConfiguration
# to: o.s.c.n.e.c.DiscoveryClientOptionalArgsConfiguration
while len(logger_name) > length:
logger_name, count = logger_seg_regex.subn(r'\1.', logger_name, 1)
if count < 1:
break

# then, cut off the leading packages if necessary
logger_name = logger_name[-length:]
record['logger'] = logger_name
return record

return shortener

def build_formatter():
'''
Build the log line formatter based on the format_json argument.
'''
nonlocal format_json

def identity(o):
return o

if format_json is None or len(format_json) == 0:
return identity

logger_regex = re.compile(r'\blogger\{(\d+)\}')
match = logger_regex.search(format_json)
pre_processor = identity
if match:
length = int(match[1])
pre_processor = build_log_shortener(length)
format_json = logger_regex.sub('logger', format_json, 1)

first_exception = True

def format_line(line):
nonlocal first_exception
try:
log_record = json.loads(line)
# Add n=\n so that in Windows CMD it's easy to specify customized format with line ending
# e.g., "{timestamp} {message}{n}"
# (Windows CMD does not escape \n in string literal.)
return format_json.format_map(pre_processor(defaultdict(str, n="\n", **log_record)))
except:
if first_exception:
# enable this format error logging only with --verbose
logger.info("Failed to format log line '{}'".format(line), exc_info=sys.exc_info())
first_exception = False
return line

return format_line

def iter_lines(response, limit=2**20):
'''
Returns a line iterator from the response content. If no line ending was found and the buffered content size is
larger than the limit, the buffer will be yielded directly.
'''
buffer = []
total = 0
for content in response.iter_content(chunk_size=None):
if not content:
if len(buffer) > 0:
yield b''.join(buffer)
break

start = 0
while start < len(content):
line_end = content.find(b'\n', start)
should_print = False
if line_end < 0:
next = (content if start == 0 else content[start:])
buffer.append(next)
total += len(next)
start = len(content)
should_print = total >= limit
else:
buffer.append(content[start:line_end + 1])
start = line_end + 1
should_print = True

if should_print:
yield b''.join(buffer)
buffer.clear()
total = 0

with requests.get(url, stream=True, auth=HTTPBasicAuth(user_name, password)) as response:
try:
if response.status_code != 200:
raise CLIError("Failed to connect to the server with status code '{}' and reason '{}'".format(
response.status_code, response.reason))
std_encoding = sys.stdout.encoding
for content in response.iter_content():
if content:
sys.stdout.write(content.decode(encoding='utf-8', errors='replace')
.encode(std_encoding, errors='replace')
.decode(std_encoding, errors='replace'))

formatter = build_formatter()

for line in iter_lines(response):
decoded = (line.decode(encoding='utf-8', errors='replace')
.encode(std_encoding, errors='replace')
.decode(std_encoding, errors='replace'))
print(formatter(decoded), end='')

except CLIError as e:
exceptions.append(e)

Expand Down
2 changes: 1 addition & 1 deletion src/spring-cloud/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

# TODO: Confirm this is the right version number you want and it matches your
# HISTORY.rst entry.
VERSION = '2.3.1'
VERSION = '2.4.0'

# The full list of classifiers is available at
# https://pypi.python.org/pypi?%3Aaction=list_classifiers
Expand Down

0 comments on commit 6357f87

Please sign in to comment.