Skip to content

feat: trace improvements #450

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 25 commits into from
Dec 10, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
9fe2d8a
added trace_sampled detection
daniel-sanche Nov 22, 2021
3f464b5
implemented traceparent detection
daniel-sanche Nov 22, 2021
c77657a
check for both header fields
daniel-sanche Nov 23, 2021
310ae13
fixed GKE handler
daniel-sanche Nov 23, 2021
98bd28c
always return 4-tuple
daniel-sanche Nov 23, 2021
93d0e61
🦉 Updates from OwlBot
gcf-owl-bot[bot] Nov 23, 2021
e8785cb
fixed unit tests
daniel-sanche Dec 1, 2021
3fd0899
added tests for w3c traceparent
daniel-sanche Dec 2, 2021
899dd93
added tests for different trace types
daniel-sanche Dec 2, 2021
cb8bdfb
added trace_sampled to structured log handler
daniel-sanche Dec 2, 2021
d652588
added tests for structured log handler integration
daniel-sanche Dec 2, 2021
0563239
Merge branch 'trace_improvements' of github.com:googleapis/python-log…
daniel-sanche Dec 2, 2021
0d53025
updated submodule
daniel-sanche Dec 2, 2021
c9bc772
lint fixes
daniel-sanche Dec 2, 2021
d9b4f0a
added extra system test
daniel-sanche Dec 2, 2021
8662338
updated submodule
daniel-sanche Dec 2, 2021
a874e9a
fixed structured log handler string interpretation issue
daniel-sanche Dec 2, 2021
0286522
updated submodule
daniel-sanche Dec 6, 2021
b30f9e7
added more test cases
daniel-sanche Dec 7, 2021
7e521db
updated submodule
daniel-sanche Dec 7, 2021
e395a38
Merge remote-tracking branch 'origin/v3.0.0' into trace_improvements
daniel-sanche Dec 7, 2021
438b3ee
default trace_sampled to False
daniel-sanche Dec 7, 2021
a6c6047
updated submodule
daniel-sanche Dec 7, 2021
bcbb318
updated submodule
daniel-sanche Dec 7, 2021
b3acb10
changed logic for trace extraction
daniel-sanche Dec 7, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 81 additions & 36 deletions google/cloud/logging_v2/handlers/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@
from google.cloud.logging_v2.handlers.middleware.request import _get_django_request

_DJANGO_CONTENT_LENGTH = "CONTENT_LENGTH"
_DJANGO_TRACE_HEADER = "HTTP_X_CLOUD_TRACE_CONTEXT"
_DJANGO_XCLOUD_TRACE_HEADER = "HTTP_X_CLOUD_TRACE_CONTEXT"
_DJANGO_TRACEPARENT = "HTTP_TRACEPARENT"
_DJANGO_USERAGENT_HEADER = "HTTP_USER_AGENT"
_DJANGO_REMOTE_ADDR_HEADER = "REMOTE_ADDR"
_DJANGO_REFERER_HEADER = "HTTP_REFERER"
_FLASK_TRACE_HEADER = "X_CLOUD_TRACE_CONTEXT"
_FLASK_XCLOUD_TRACE_HEADER = "X_CLOUD_TRACE_CONTEXT"
_FLASK_TRACEPARENT = "TRACEPARENT"
Comment on lines -30 to +36
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: since these headers are standardized, is there a need to maintain an instance per web server framework?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, for some reason django uses "HTTP_" as a prefix when reading most HTTP headers.

At some point as we add more framework support, we may want to revisit this to add the prefix later in django code instead of in the constant, but for now I'd prefer to leave it as-is

_PROTOCOL_HEADER = "SERVER_PROTOCOL"


Expand Down Expand Up @@ -62,13 +64,12 @@ def get_request_data_from_flask():
"""Get http_request and trace data from flask request headers.

Returns:
Tuple[Optional[dict], Optional[str], Optional[str]]:
Data related to the current http request, trace_id, and span_id for
the request. All fields will be None if a django request isn't
found.
Tuple[Optional[dict], Optional[str], Optional[str], bool]:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: shouldn't bool be optional too?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the LogEntry spec says if the sampling decision is uncertain, you should default the trace_sampled field to False. So this function will always return True or False for that field

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, the code immediately below returns None.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh good catch! I started with it being optional before switching. I'll look through the code again

Data related to the current http request, trace_id, span_id and trace_sampled
for the request. All fields will be None if a django request isn't found.
"""
if flask is None or not flask.request:
return None, None, None
return None, None, None, False

# build http_request
http_request = {
Expand All @@ -79,25 +80,29 @@ def get_request_data_from_flask():
}

# find trace id and span id
header = flask.request.headers.get(_FLASK_TRACE_HEADER)
trace_id, span_id = _parse_trace_span(header)
# first check for w3c traceparent header
header = flask.request.headers.get(_FLASK_TRACEPARENT)
trace_id, span_id, trace_sampled = _parse_trace_parent(header)
if trace_id is None:
# traceparent not found. look for xcloud_trace_context header
header = flask.request.headers.get(_FLASK_XCLOUD_TRACE_HEADER)
trace_id, span_id, trace_sampled = _parse_xcloud_trace(header)

return http_request, trace_id, span_id
return http_request, trace_id, span_id, trace_sampled


def get_request_data_from_django():
"""Get http_request and trace data from django request headers.

Returns:
Tuple[Optional[dict], Optional[str], Optional[str]]:
Data related to the current http request, trace_id, and span_id for
the request. All fields will be None if a django request isn't
found.
Tuple[Optional[dict], Optional[str], Optional[str], bool]:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: same question about Optional[] here

Data related to the current http request, trace_id, span_id, and trace_sampled
for the request. All fields will be None if a django request isn't found.
"""
request = _get_django_request()

if request is None:
return None, None, None
return None, None, None, False

# build http_request
http_request = {
Expand All @@ -108,54 +113,94 @@ def get_request_data_from_django():
}

# find trace id and span id
header = request.META.get(_DJANGO_TRACE_HEADER)
trace_id, span_id = _parse_trace_span(header)
# first check for w3c traceparent header
header = request.META.get(_DJANGO_TRACEPARENT)
trace_id, span_id, trace_sampled = _parse_trace_parent(header)
if trace_id is None:
# traceparent not found. look for xcloud_trace_context header
header = request.META.get(_DJANGO_XCLOUD_TRACE_HEADER)
trace_id, span_id, trace_sampled = _parse_xcloud_trace(header)

return http_request, trace_id, span_id
return http_request, trace_id, span_id, trace_sampled


def _parse_trace_span(header):
def _parse_trace_parent(header):
"""Given a w3 traceparent header, extract the trace and span ids.
For more information see https://www.w3.org/TR/trace-context/

Args:
header (str): the string extracted from the traceparent header
example: 00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01
Returns:
Tuple[Optional[dict], Optional[str], bool]:
The trace_id, span_id and trace_sampled extracted from the header
Each field will be None if header can't be parsed in expected format.
"""
trace_id = span_id = None
trace_sampled = False
# see https://www.w3.org/TR/trace-context/ for W3C traceparent format
if header:
try:
VERSION_PART = r"(?!ff)[a-f\d]{2}"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: right now the version must be 00. other versions are considered invalid.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How are you handling other versions? Just ignoring anything other than 00?

It looks like nodejs is assuming forward-compatibility, based on this part of the spec ("The current version of this specification assumes that future versions of the traceparent header will be additive to the current one."). I'm fine with locking to version 00 for now though if we decide that's safer. Let me know if you have thoughts.

TRACE_ID_PART = r"(?![0]{32})[a-f\d]{32}"
PARENT_ID_PART = r"(?![0]{16})[a-f\d]{16}"
FLAGS_PART = r"[a-f\d]{2}"
regex = f"^\\s?({VERSION_PART})-({TRACE_ID_PART})-({PARENT_ID_PART})-({FLAGS_PART})(-.*)?\\s?$"
match = re.match(regex, header)
trace_id = match.group(2)
span_id = match.group(3)
# trace-flag component is an 8-bit bit field. Read as an int
int_flag = int(match.group(4), 16)
# trace sampled is set if the right-most bit in flag component is set
trace_sampled = bool(int_flag & 1)
except (IndexError, AttributeError):
# could not parse header as expected. Return None
pass
return trace_id, span_id, trace_sampled


def _parse_xcloud_trace(header):
"""Given an X_CLOUD_TRACE header, extract the trace and span ids.

Args:
header (str): the string extracted from the X_CLOUD_TRACE header
Returns:
Tuple[Optional[dict], Optional[str]]:
The trace_id and span_id extracted from the header
Tuple[Optional[dict], Optional[str], bool]:
The trace_id, span_id and trace_sampled extracted from the header
Each field will be None if not found.
"""
trace_id = None
span_id = None
trace_id = span_id = None
trace_sampled = False
# see https://cloud.google.com/trace/docs/setup for X-Cloud-Trace_Context format
if header:
try:
split_header = header.split("/", 1)
trace_id = split_header[0]
header_suffix = split_header[1]
# the span is the set of alphanumeric characters after the /
span_id = re.findall(r"^\w+", header_suffix)[0]
regex = r"([\w-]+)?(\/?([\w-]+))?(;?o=(\d))?"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: according to our doc TRACE_ID is 32-character hexadec value that can be presented via acceptible UUID formats. SPAN_ID is a decimal value. adding it because tracing of the w3c was more strict. 🙂

Copy link
Contributor Author

@daniel-sanche daniel-sanche Dec 7, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I'm kind of curious on your thoughts here. I made the w3c code more strict, because the spec is pretty explicit about dropping invalid headers (which makes sense for an open spec if you want to ensure all implementations are interoperable).

I thought we might want to be a bit more permissible in the X-Cloud-Trace-Context parsing, and leave the correct formatting to the user. But maybe it would make sense to be strict for both?

match = re.match(regex, header)
trace_id = match.group(1)
span_id = match.group(3)
trace_sampled = match.group(5) == "1"
except IndexError:
pass
return trace_id, span_id
return trace_id, span_id, trace_sampled


def get_request_data():
"""Helper to get http_request and trace data from supported web
frameworks (currently supported: Flask and Django).

Returns:
Tuple[Optional[dict], Optional[str], Optional[str]]:
Data related to the current http request, trace_id, and span_id for
the request. All fields will be None if a django request isn't
found.
Tuple[Optional[dict], Optional[str], Optional[str], bool]:
Data related to the current http request, trace_id, span_id, and trace_sampled
for the request. All fields will be None if a http request isn't found.
"""
checkers = (
get_request_data_from_django,
get_request_data_from_flask,
)

for checker in checkers:
http_request, trace_id, span_id = checker()
http_request, trace_id, span_id, trace_sampled = checker()
if http_request is not None:
return http_request, trace_id, span_id
return http_request, trace_id, span_id, trace_sampled

return None, None, None
return None, None, None, False
4 changes: 2 additions & 2 deletions google/cloud/logging_v2/handlers/app_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ def get_gae_labels(self):
"""
gae_labels = {}

_, trace_id, _ = get_request_data()
_, trace_id, _, _ = get_request_data()
if trace_id is not None:
gae_labels[_TRACE_ID_LABEL] = trace_id

Expand All @@ -115,7 +115,7 @@ def emit(self, record):
record (logging.LogRecord): The record to be logged.
"""
message = super(AppEngineHandler, self).format(record)
inferred_http, inferred_trace, _ = get_request_data()
inferred_http, inferred_trace, _, _ = get_request_data()
if inferred_trace is not None:
inferred_trace = f"projects/{self.project_id}/traces/{inferred_trace}"
# allow user overrides
Expand Down
10 changes: 9 additions & 1 deletion google/cloud/logging_v2/handlers/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,20 @@ def filter(self, record):
"""
user_labels = getattr(record, "labels", {})
# infer request data from the environment
inferred_http, inferred_trace, inferred_span = get_request_data()
(
inferred_http,
inferred_trace,
inferred_span,
inferred_sampled,
) = get_request_data()
if inferred_trace is not None and self.project is not None:
# add full path for detected trace
inferred_trace = f"projects/{self.project}/traces/{inferred_trace}"
# set new record values
record._resource = getattr(record, "resource", None)
record._trace = getattr(record, "trace", inferred_trace) or None
record._span_id = getattr(record, "span_id", inferred_span) or None
record._trace_sampled = bool(getattr(record, "trace_sampled", inferred_sampled))
record._http_request = getattr(record, "http_request", inferred_http)
record._source_location = CloudLoggingFilter._infer_source_location(record)
# add logger name as a label if possible
Expand All @@ -98,6 +104,7 @@ def filter(self, record):
# create string representations for structured logging
record._trace_str = record._trace or ""
record._span_id_str = record._span_id or ""
record._trace_sampled_str = "true" if record._trace_sampled else "false"
record._http_request_str = json.dumps(
record._http_request or {}, ensure_ascii=False
)
Expand Down Expand Up @@ -205,6 +212,7 @@ def emit(self, record):
labels=labels,
trace=record._trace,
span_id=record._span_id,
trace_sampled=record._trace_sampled,
http_request=record._http_request,
source_location=record._source_location,
)
Expand Down
1 change: 1 addition & 0 deletions google/cloud/logging_v2/handlers/structured_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
'"logging.googleapis.com/labels": %(_labels_str)s, '
'"logging.googleapis.com/trace": "%(_trace_str)s", '
'"logging.googleapis.com/spanId": "%(_span_id_str)s", '
'"logging.googleapis.com/trace_sampled": %(_trace_sampled_str)s, '
'"logging.googleapis.com/sourceLocation": %(_source_location_str)s, '
'"httpRequest": %(_http_request_str)s '
"}"
Expand Down
2 changes: 1 addition & 1 deletion tests/environment
4 changes: 4 additions & 0 deletions tests/system/test_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@ def test_log_empty(self):

self.assertEqual(len(entries), 1)
self.assertIsNone(entries[0].payload)
self.assertFalse(entries[0].trace_sampled)

def test_log_struct_logentry_data(self):
logger = Config.CLIENT.logger(self._logger_name("log_w_struct"))
Expand All @@ -473,6 +474,7 @@ def test_log_struct_logentry_data(self):
self.assertEqual(entries[0].severity, "WARNING")
self.assertEqual(entries[0].trace, JSON_PAYLOAD["trace"])
self.assertEqual(entries[0].span_id, JSON_PAYLOAD["span_id"])
self.assertFalse(entries[0].trace_sampled)

def test_log_handler_async(self):
LOG_MESSAGE = "It was the worst of times"
Expand Down Expand Up @@ -534,6 +536,7 @@ def test_handlers_w_extras(self):
extra = {
"trace": "123",
"span_id": "456",
"trace_sampled": True,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: do you want to add a regression test (sampled=false or omitted)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point, I added a couple more assertions to this file

"http_request": expected_request,
"source_location": expected_source,
"resource": Resource(type="cloudiot_device", labels={}),
Expand All @@ -545,6 +548,7 @@ def test_handlers_w_extras(self):
self.assertEqual(len(entries), 1)
self.assertEqual(entries[0].trace, extra["trace"])
self.assertEqual(entries[0].span_id, extra["span_id"])
self.assertTrue(entries[0].trace_sampled)
self.assertEqual(entries[0].http_request, expected_request)
self.assertEqual(
entries[0].labels, {**extra["labels"], "python_logger": LOGGER_NAME}
Expand Down
Loading