1
1
import json
2
2
import logging
3
3
import os
4
+ from typing import Dict , Iterable , Optional , Union
5
+
6
+ from ..shared import constants
7
+
8
+ STD_LOGGING_KEYS = (
9
+ "name" ,
10
+ "msg" ,
11
+ "args" ,
12
+ "levelname" ,
13
+ "levelno" ,
14
+ "pathname" ,
15
+ "filename" ,
16
+ "module" ,
17
+ "exc_info" ,
18
+ "exc_text" ,
19
+ "stack_info" ,
20
+ "lineno" ,
21
+ "funcName" ,
22
+ "created" ,
23
+ "msecs" ,
24
+ "relativeCreated" ,
25
+ "thread" ,
26
+ "threadName" ,
27
+ "processName" ,
28
+ "process" ,
29
+ "asctime" ,
30
+ )
4
31
5
32
6
33
class JsonFormatter (logging .Formatter ):
@@ -30,12 +57,12 @@ def __init__(self, **kwargs):
30
57
# Set the default unserializable function, by default values will be cast as str.
31
58
self .default_json_formatter = kwargs .pop ("json_default" , str )
32
59
# Set the insertion order for the log messages
33
- self .format_dict = dict .fromkeys (kwargs .pop ("log_record_order" , ["level" , "location" , "message" , "timestamp" ]))
60
+ self .log_format = dict .fromkeys (kwargs .pop ("log_record_order" , ["level" , "location" , "message" , "timestamp" ]))
34
61
self .reserved_keys = ["timestamp" , "level" , "location" ]
35
62
# Set the date format used by `asctime`
36
63
super (JsonFormatter , self ).__init__ (datefmt = kwargs .pop ("datefmt" , None ))
37
64
38
- self .format_dict .update (self ._build_root_keys (** kwargs ))
65
+ self .log_format .update (self ._build_root_keys (** kwargs ))
39
66
40
67
@staticmethod
41
68
def _build_root_keys (** kwargs ):
@@ -48,53 +75,99 @@ def _build_root_keys(**kwargs):
48
75
49
76
@staticmethod
50
77
def _get_latest_trace_id ():
51
- xray_trace_id = os .getenv ("_X_AMZN_TRACE_ID" )
52
- trace_id = xray_trace_id .split (";" )[0 ].replace ("Root=" , "" ) if xray_trace_id else None
53
-
54
- return trace_id
78
+ xray_trace_id = os .getenv (constants .XRAY_TRACE_ID_ENV )
79
+ return xray_trace_id .split (";" )[0 ].replace ("Root=" , "" ) if xray_trace_id else None
55
80
56
81
def update_formatter (self , ** kwargs ):
57
- self .format_dict .update (kwargs )
82
+ self .log_format .update (kwargs )
58
83
59
- def format (self , record ): # noqa: A003
60
- record_dict = record .__dict__ .copy ()
61
- record_dict ["asctime" ] = self .formatTime (record , self .datefmt )
84
+ @staticmethod
85
+ def _extract_log_message (log_record : logging .LogRecord ) -> Union [Dict , str , bool , Iterable ]:
86
+ """Extract message from log record and attempt to JSON decode it
87
+
88
+ Parameters
89
+ ----------
90
+ log_record : logging.LogRecord
91
+ Log record to extract message from
92
+
93
+ Returns
94
+ -------
95
+ message: Union[Dict, str, bool, Iterable]
96
+ Extracted message
97
+ """
98
+ if isinstance (log_record .msg , dict ):
99
+ return log_record .msg
62
100
63
- log_dict = {}
101
+ message : str = log_record . getMessage ()
64
102
65
- for key , value in self .format_dict .items ():
66
- if value and key in self .reserved_keys :
67
- # converts default logging expr to its record value
68
- # e.g. '%(asctime)s' to '2020-04-24 09:35:40,698'
69
- log_dict [key ] = value % record_dict
70
- else :
71
- log_dict [key ] = value
103
+ # Attempt to decode non-str messages e.g. msg = '{"x": "y"}'
104
+ try :
105
+ message = json .loads (log_record .msg )
106
+ except (json .decoder .JSONDecodeError , TypeError , ValueError ):
107
+ pass
108
+
109
+ return message
110
+
111
+ def _extract_log_exception (self , log_record : logging .LogRecord ) -> Optional [str ]:
112
+ """Format traceback information, if available
72
113
73
- if isinstance (record_dict ["msg" ], dict ):
74
- log_dict ["message" ] = record_dict ["msg" ]
75
- else :
76
- log_dict ["message" ] = record .getMessage ()
114
+ Parameters
115
+ ----------
116
+ log_record : logging.LogRecord
117
+ Log record to extract message from
118
+
119
+ Returns
120
+ -------
121
+ log_record: Optional[str]
122
+ Log record with constant traceback info
123
+ """
124
+ if log_record .exc_info :
125
+ return self .formatException (log_record .exc_info )
77
126
78
- # Attempt to decode the message as JSON, if so, merge it with the
79
- # overall message for clarity.
80
- try :
81
- log_dict ["message" ] = json .loads (log_dict ["message" ])
82
- except (json .decoder .JSONDecodeError , TypeError , ValueError ):
83
- pass
127
+ return None
84
128
85
- if record .exc_info and not record .exc_text :
86
- # Cache the traceback text to avoid converting it multiple times
87
- # (it's constant anyway)
88
- # from logging.Formatter:format
89
- record .exc_text = self .formatException (record .exc_info )
129
+ def _extract_log_keys (self , log_record : logging .LogRecord ) -> Dict :
130
+ """Extract and parse custom and reserved log keys
90
131
91
- if record .exc_text :
92
- log_dict ["exception" ] = record .exc_text
132
+ Parameters
133
+ ----------
134
+ log_record : logging.LogRecord
135
+ Log record to extract keys from
93
136
94
- # fetch latest X-Ray Trace ID, if any
95
- log_dict .update ({"xray_trace_id" : self ._get_latest_trace_id ()})
137
+ Returns
138
+ -------
139
+ formatted_log: Dict
140
+ Structured log as dictionary
141
+ """
142
+ record_dict = log_record .__dict__ .copy () # has extra kwargs we are after
143
+ record_dict ["asctime" ] = self .formatTime (log_record , self .datefmt )
144
+
145
+ formatted_log = {}
146
+
147
+ # We have to iterate over a default or existing log structure
148
+ # then replace any logging expression for reserved keys e.g. '%(level)s' to 'INFO'
149
+ # and lastly add or replace incoming keys (those added within the constructor or .structure_logs method)
150
+ for key , value in self .log_format .items ():
151
+ if value and key in self .reserved_keys :
152
+ formatted_log [key ] = value % record_dict
153
+ else :
154
+ formatted_log [key ] = value
155
+
156
+ # pick up extra keys when logging a new message e.g. log.info("my message", extra={"additional_key": "value"}
157
+ # these messages will be added to the root of the final structure not within `message` key
158
+ for key , value in record_dict .items ():
159
+ if key not in STD_LOGGING_KEYS :
160
+ formatted_log [key ] = value
161
+
162
+ return formatted_log
163
+
164
+ def format (self , record ): # noqa: A003
165
+ formatted_log = self ._extract_log_keys (log_record = record )
166
+ formatted_log ["message" ] = self ._extract_log_message (log_record = record )
167
+ formatted_log ["exception" ] = self ._extract_log_exception (log_record = record )
168
+ formatted_log .update ({"xray_trace_id" : self ._get_latest_trace_id ()}) # fetch latest Trace ID, if any
96
169
97
170
# Filter out top level key with values that are None
98
- log_dict = {k : v for k , v in log_dict .items () if v is not None }
171
+ formatted_log = {k : v for k , v in formatted_log .items () if v is not None }
99
172
100
- return json .dumps (log_dict , default = self .default_json_formatter )
173
+ return json .dumps (formatted_log , default = self .default_json_formatter )
0 commit comments