Skip to content

Commit a24a515

Browse files
committed
feat: trace improvements (#450)
1 parent c233f38 commit a24a515

File tree

8 files changed

+378
-101
lines changed

8 files changed

+378
-101
lines changed

google/cloud/logging_v2/handlers/_helpers.py

+81-36
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,13 @@
2727
from google.cloud.logging_v2.handlers.middleware.request import _get_django_request
2828

2929
_DJANGO_CONTENT_LENGTH = "CONTENT_LENGTH"
30-
_DJANGO_TRACE_HEADER = "HTTP_X_CLOUD_TRACE_CONTEXT"
30+
_DJANGO_XCLOUD_TRACE_HEADER = "HTTP_X_CLOUD_TRACE_CONTEXT"
31+
_DJANGO_TRACEPARENT = "HTTP_TRACEPARENT"
3132
_DJANGO_USERAGENT_HEADER = "HTTP_USER_AGENT"
3233
_DJANGO_REMOTE_ADDR_HEADER = "REMOTE_ADDR"
3334
_DJANGO_REFERER_HEADER = "HTTP_REFERER"
34-
_FLASK_TRACE_HEADER = "X_CLOUD_TRACE_CONTEXT"
35+
_FLASK_XCLOUD_TRACE_HEADER = "X_CLOUD_TRACE_CONTEXT"
36+
_FLASK_TRACEPARENT = "TRACEPARENT"
3537
_PROTOCOL_HEADER = "SERVER_PROTOCOL"
3638

3739

@@ -62,13 +64,12 @@ def get_request_data_from_flask():
6264
"""Get http_request and trace data from flask request headers.
6365
6466
Returns:
65-
Tuple[Optional[dict], Optional[str], Optional[str]]:
66-
Data related to the current http request, trace_id, and span_id for
67-
the request. All fields will be None if a django request isn't
68-
found.
67+
Tuple[Optional[dict], Optional[str], Optional[str], bool]:
68+
Data related to the current http request, trace_id, span_id and trace_sampled
69+
for the request. All fields will be None if a django request isn't found.
6970
"""
7071
if flask is None or not flask.request:
71-
return None, None, None
72+
return None, None, None, False
7273

7374
# build http_request
7475
http_request = {
@@ -79,25 +80,29 @@ def get_request_data_from_flask():
7980
}
8081

8182
# find trace id and span id
82-
header = flask.request.headers.get(_FLASK_TRACE_HEADER)
83-
trace_id, span_id = _parse_trace_span(header)
83+
# first check for w3c traceparent header
84+
header = flask.request.headers.get(_FLASK_TRACEPARENT)
85+
trace_id, span_id, trace_sampled = _parse_trace_parent(header)
86+
if trace_id is None:
87+
# traceparent not found. look for xcloud_trace_context header
88+
header = flask.request.headers.get(_FLASK_XCLOUD_TRACE_HEADER)
89+
trace_id, span_id, trace_sampled = _parse_xcloud_trace(header)
8490

85-
return http_request, trace_id, span_id
91+
return http_request, trace_id, span_id, trace_sampled
8692

8793

8894
def get_request_data_from_django():
8995
"""Get http_request and trace data from django request headers.
9096
9197
Returns:
92-
Tuple[Optional[dict], Optional[str], Optional[str]]:
93-
Data related to the current http request, trace_id, and span_id for
94-
the request. All fields will be None if a django request isn't
95-
found.
98+
Tuple[Optional[dict], Optional[str], Optional[str], bool]:
99+
Data related to the current http request, trace_id, span_id, and trace_sampled
100+
for the request. All fields will be None if a django request isn't found.
96101
"""
97102
request = _get_django_request()
98103

99104
if request is None:
100-
return None, None, None
105+
return None, None, None, False
101106

102107
# build http_request
103108
http_request = {
@@ -108,54 +113,94 @@ def get_request_data_from_django():
108113
}
109114

110115
# find trace id and span id
111-
header = request.META.get(_DJANGO_TRACE_HEADER)
112-
trace_id, span_id = _parse_trace_span(header)
116+
# first check for w3c traceparent header
117+
header = request.META.get(_DJANGO_TRACEPARENT)
118+
trace_id, span_id, trace_sampled = _parse_trace_parent(header)
119+
if trace_id is None:
120+
# traceparent not found. look for xcloud_trace_context header
121+
header = request.META.get(_DJANGO_XCLOUD_TRACE_HEADER)
122+
trace_id, span_id, trace_sampled = _parse_xcloud_trace(header)
113123

114-
return http_request, trace_id, span_id
124+
return http_request, trace_id, span_id, trace_sampled
115125

116126

117-
def _parse_trace_span(header):
127+
def _parse_trace_parent(header):
128+
"""Given a w3 traceparent header, extract the trace and span ids.
129+
For more information see https://www.w3.org/TR/trace-context/
130+
131+
Args:
132+
header (str): the string extracted from the traceparent header
133+
example: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
134+
Returns:
135+
Tuple[Optional[dict], Optional[str], bool]:
136+
The trace_id, span_id and trace_sampled extracted from the header
137+
Each field will be None if header can't be parsed in expected format.
138+
"""
139+
trace_id = span_id = None
140+
trace_sampled = False
141+
# see https://www.w3.org/TR/trace-context/ for W3C traceparent format
142+
if header:
143+
try:
144+
VERSION_PART = r"(?!ff)[a-f\d]{2}"
145+
TRACE_ID_PART = r"(?![0]{32})[a-f\d]{32}"
146+
PARENT_ID_PART = r"(?![0]{16})[a-f\d]{16}"
147+
FLAGS_PART = r"[a-f\d]{2}"
148+
regex = f"^\\s?({VERSION_PART})-({TRACE_ID_PART})-({PARENT_ID_PART})-({FLAGS_PART})(-.*)?\\s?$"
149+
match = re.match(regex, header)
150+
trace_id = match.group(2)
151+
span_id = match.group(3)
152+
# trace-flag component is an 8-bit bit field. Read as an int
153+
int_flag = int(match.group(4), 16)
154+
# trace sampled is set if the right-most bit in flag component is set
155+
trace_sampled = bool(int_flag & 1)
156+
except (IndexError, AttributeError):
157+
# could not parse header as expected. Return None
158+
pass
159+
return trace_id, span_id, trace_sampled
160+
161+
162+
def _parse_xcloud_trace(header):
118163
"""Given an X_CLOUD_TRACE header, extract the trace and span ids.
119164
120165
Args:
121166
header (str): the string extracted from the X_CLOUD_TRACE header
122167
Returns:
123-
Tuple[Optional[dict], Optional[str]]:
124-
The trace_id and span_id extracted from the header
168+
Tuple[Optional[dict], Optional[str], bool]:
169+
The trace_id, span_id and trace_sampled extracted from the header
125170
Each field will be None if not found.
126171
"""
127-
trace_id = None
128-
span_id = None
172+
trace_id = span_id = None
173+
trace_sampled = False
174+
# see https://cloud.google.com/trace/docs/setup for X-Cloud-Trace_Context format
129175
if header:
130176
try:
131-
split_header = header.split("/", 1)
132-
trace_id = split_header[0]
133-
header_suffix = split_header[1]
134-
# the span is the set of alphanumeric characters after the /
135-
span_id = re.findall(r"^\w+", header_suffix)[0]
177+
regex = r"([\w-]+)?(\/?([\w-]+))?(;?o=(\d))?"
178+
match = re.match(regex, header)
179+
trace_id = match.group(1)
180+
span_id = match.group(3)
181+
trace_sampled = match.group(5) == "1"
136182
except IndexError:
137183
pass
138-
return trace_id, span_id
184+
return trace_id, span_id, trace_sampled
139185

140186

141187
def get_request_data():
142188
"""Helper to get http_request and trace data from supported web
143189
frameworks (currently supported: Flask and Django).
144190
145191
Returns:
146-
Tuple[Optional[dict], Optional[str], Optional[str]]:
147-
Data related to the current http request, trace_id, and span_id for
148-
the request. All fields will be None if a django request isn't
149-
found.
192+
Tuple[Optional[dict], Optional[str], Optional[str], bool]:
193+
Data related to the current http request, trace_id, span_id, and trace_sampled
194+
for the request. All fields will be None if a http request isn't found.
150195
"""
151196
checkers = (
152197
get_request_data_from_django,
153198
get_request_data_from_flask,
154199
)
155200

156201
for checker in checkers:
157-
http_request, trace_id, span_id = checker()
202+
http_request, trace_id, span_id, trace_sampled = checker()
158203
if http_request is not None:
159-
return http_request, trace_id, span_id
204+
return http_request, trace_id, span_id, trace_sampled
160205

161-
return None, None, None
206+
return None, None, None, False

google/cloud/logging_v2/handlers/app_engine.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ def get_gae_labels(self):
9898
"""
9999
gae_labels = {}
100100

101-
_, trace_id, _ = get_request_data()
101+
_, trace_id, _, _ = get_request_data()
102102
if trace_id is not None:
103103
gae_labels[_TRACE_ID_LABEL] = trace_id
104104

@@ -115,7 +115,7 @@ def emit(self, record):
115115
record (logging.LogRecord): The record to be logged.
116116
"""
117117
message = super(AppEngineHandler, self).format(record)
118-
inferred_http, inferred_trace, _ = get_request_data()
118+
inferred_http, inferred_trace, _, _ = get_request_data()
119119
if inferred_trace is not None:
120120
inferred_trace = f"projects/{self.project_id}/traces/{inferred_trace}"
121121
# allow user overrides

google/cloud/logging_v2/handlers/handlers.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,20 @@ def filter(self, record):
8282
"""
8383
user_labels = getattr(record, "labels", {})
8484
# infer request data from the environment
85-
inferred_http, inferred_trace, inferred_span = get_request_data()
85+
(
86+
inferred_http,
87+
inferred_trace,
88+
inferred_span,
89+
inferred_sampled,
90+
) = get_request_data()
8691
if inferred_trace is not None and self.project is not None:
8792
# add full path for detected trace
8893
inferred_trace = f"projects/{self.project}/traces/{inferred_trace}"
8994
# set new record values
9095
record._resource = getattr(record, "resource", None)
9196
record._trace = getattr(record, "trace", inferred_trace) or None
9297
record._span_id = getattr(record, "span_id", inferred_span) or None
98+
record._trace_sampled = bool(getattr(record, "trace_sampled", inferred_sampled))
9399
record._http_request = getattr(record, "http_request", inferred_http)
94100
record._source_location = CloudLoggingFilter._infer_source_location(record)
95101
# add logger name as a label if possible
@@ -98,6 +104,7 @@ def filter(self, record):
98104
# create string representations for structured logging
99105
record._trace_str = record._trace or ""
100106
record._span_id_str = record._span_id or ""
107+
record._trace_sampled_str = "true" if record._trace_sampled else "false"
101108
record._http_request_str = json.dumps(
102109
record._http_request or {}, ensure_ascii=False
103110
)
@@ -205,6 +212,7 @@ def emit(self, record):
205212
labels=labels,
206213
trace=record._trace,
207214
span_id=record._span_id,
215+
trace_sampled=record._trace_sampled,
208216
http_request=record._http_request,
209217
source_location=record._source_location,
210218
)

tests/system/test_system.py

+4
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,7 @@ def test_log_empty(self):
454454

455455
self.assertEqual(len(entries), 1)
456456
self.assertIsNone(entries[0].payload)
457+
self.assertFalse(entries[0].trace_sampled)
457458

458459
def test_log_struct_logentry_data(self):
459460
logger = Config.CLIENT.logger(self._logger_name("log_w_struct"))
@@ -473,6 +474,7 @@ def test_log_struct_logentry_data(self):
473474
self.assertEqual(entries[0].severity, "WARNING")
474475
self.assertEqual(entries[0].trace, JSON_PAYLOAD["trace"])
475476
self.assertEqual(entries[0].span_id, JSON_PAYLOAD["span_id"])
477+
self.assertFalse(entries[0].trace_sampled)
476478

477479
def test_log_handler_async(self):
478480
LOG_MESSAGE = "It was the worst of times"
@@ -534,6 +536,7 @@ def test_handlers_w_extras(self):
534536
extra = {
535537
"trace": "123",
536538
"span_id": "456",
539+
"trace_sampled": True,
537540
"http_request": expected_request,
538541
"source_location": expected_source,
539542
"resource": Resource(type="cloudiot_device", labels={}),
@@ -545,6 +548,7 @@ def test_handlers_w_extras(self):
545548
self.assertEqual(len(entries), 1)
546549
self.assertEqual(entries[0].trace, extra["trace"])
547550
self.assertEqual(entries[0].span_id, extra["span_id"])
551+
self.assertTrue(entries[0].trace_sampled)
548552
self.assertEqual(entries[0].http_request, expected_request)
549553
self.assertEqual(
550554
entries[0].labels, {**extra["labels"], "python_logger": LOGGER_NAME}

0 commit comments

Comments
 (0)