xref: /aosp_15_r20/external/autotest/utils/frozen_chromite/lib/cloud_trace.py (revision 9c5db1993ded3edbeafc8092d69fe5de2ee02df7)
1# -*- coding: utf-8 -*-
2# Copyright 2017 The Chromium OS Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""A library for emitting traces and spans to Google Cloud trace."""
7from __future__ import print_function
8
9import contextlib
10import errno
11import functools
12import json
13import os
14import random
15import re
16
17try:
18  from google.protobuf import timestamp_pb2
19except ImportError:
20  import google.protobuf.internal.well_known_types as timestamp_pb2
21
22from infra_libs import ts_mon
23
24from autotest_lib.utils.frozen_chromite.lib import cros_logging as log
25from autotest_lib.utils.frozen_chromite.lib import metrics
26from autotest_lib.utils.frozen_chromite.lib import structured
27
28
29SPANS_LOG = '/var/log/trace/{pid}-{span_id}.json'
30_SPAN_COUNT_METRIC = 'chromeos/trace/client/logged_count'
31
32
33# --- Code for logging spans to a file for later processing. -------------------
34def GetSpanLogFilePath(span):
35  """Gets the path to write a span to.
36
37  Args:
38    span: The span to write.
39  """
40  return SPANS_LOG.format(pid=os.getpid(), span_id=span.spanId)
41
42
43def LogSpan(span):
44  """Serializes and logs a Span to a file.
45
46  Args:
47    span: A Span instance to serialize.
48  """
49  _RecordSpanMetrics(span)
50  try:
51    with open(GetSpanLogFilePath(span), 'w') as fh:
52      fh.write(json.dumps(span.ToDict()))
53  # Catch various configuration errors.
54  # TODO(vapier): Drop IOError when we're Python 3-only.
55  # pylint: disable=overlapping-except
56  except (OSError, IOError) as error:
57    if error.errno == errno.EPERM:
58      log.warning(
59          'Received permissions error while trying to open the span log file.')
60      return None
61    elif error.errno == errno.ENOENT:
62      log.warning('/var/log/traces does not exist; skipping trace log.')
63      return None
64    else:
65      raise
66
67
68def _RecordSpanMetrics(span):
69  """Increments the count of spans logged.
70
71  Args:
72    span: The span to record.
73  """
74  m = metrics.Counter(
75      _SPAN_COUNT_METRIC,
76      description='A count of spans logged by a client.',
77      field_spec=[ts_mon.StringField('name')])
78  m.increment(fields={'name': span.name})
79
80
81# -- User-facing API -----------------------------------------------------------
82class Span(structured.Structured):
83  """An object corresponding to a cloud trace Span."""
84
85  VISIBLE_KEYS = (
86      'name', 'spanId', 'parentSpanId', 'labels',
87      'startTime', 'endTime', 'status')
88
89  def __init__(self, name, spanId=None, labels=None, parentSpanId=None,
90               traceId=None):
91    """Creates a Span object.
92
93    Args:
94      name: The name of the span
95      spanId: (optional) A 64-bit number as a string. If not provided, it will
96          be generated randomly with .GenerateSpanId().
97      labels: (optional) a dict<string, string> of key/values
98      traceId: (optional) A 32 hex digit string referring to the trace
99          containing this span. If not provided, a new trace will be created
100          with a random id.
101      parentSpanId: (optional) The spanId of the parent.
102    """
103    # Visible attributes
104    self.name = name
105    self.spanId = spanId or Span.GenerateSpanId()
106    self.parentSpanId = parentSpanId
107    self.labels = labels or {}
108    self.startTime = None
109    self.endTime = None
110    # Non-visible attributes
111    self.traceId = traceId or Span.GenerateTraceId()
112
113  @staticmethod
114  def GenerateSpanId():
115    """Returns a random 64-bit number as a string."""
116    return str(random.randint(0, 2**64))
117
118  @staticmethod
119  def GenerateTraceId():
120    """Returns a random 128-bit number as a 32-byte hex string."""
121    id_number = random.randint(0, 2**128)
122    return '%0.32X' % id_number
123
124  def __enter__(self):
125    """Enters the span context.
126
127    Side effect: Records the start time as a Timestamp.
128    """
129    start = timestamp_pb2.Timestamp()
130    start.GetCurrentTime()
131    self.startTime = start.ToJsonString()
132    return self
133
134  def __exit__(self, _type, _value, _traceback):
135    """Exits the span context.
136
137    Side-effect:
138      Record the end Timestamp.
139    """
140    end = timestamp_pb2.Timestamp()
141    end.GetCurrentTime()
142    self.endTime = end.ToJsonString()
143
144
145class SpanStack(object):
146  """A stack of Span contexts."""
147
148  CLOUD_TRACE_CONTEXT_ENV = 'CLOUD_TRACE_CONTEXT'
149  CLOUD_TRACE_CONTEXT_PATTERN = re.compile(
150      r'(?P<traceId>[0-9A-Fa-f]+)'
151      r'/'
152      r'(?P<parentSpanId>\d+)'
153      r';o=(?P<options>\d+)'
154  )
155
156  def __init__(self, global_context=None, traceId=None, parentSpanId=None,
157               labels=None, enabled=True):
158    """Initializes the Span.
159
160      global_context: (optional) A global context str, perhaps read from the
161          X-Cloud-Trace-Context header.
162      traceId: (optional) A 32 hex digit string referring to the trace
163          containing this span. If not provided, a new trace will be created
164          with a random id.
165      parentSpanId: (optional) The spanId of the parent.
166      labels: (optional) a dict<string, string> of key/values to attach to
167          each Span created, or None.
168      enabled: (optional) a bool indicating whether we should log the spans
169          to a file for later uploading by the cloud trace log consumer daemon.
170    """
171    self.traceId = traceId
172    self.spans = []
173    self.last_span_id = parentSpanId
174    self.labels = labels
175    self.enabled = enabled
176
177    global_context = (global_context or
178                      os.environ.get(self.CLOUD_TRACE_CONTEXT_ENV, ''))
179    context = SpanStack._ParseCloudTraceContext(global_context)
180
181    if traceId is None:
182      self.traceId = context.get('traceId')
183    if parentSpanId is None:
184      self.last_span_id = context.get('parentSpanId')
185    if context.get('options') == '0':
186      self.enabled = False
187
188  def _CreateSpan(self, name, **kwargs):
189    """Creates a span instance, setting certain defaults.
190
191    Args:
192      name: The name of the span
193      **kwargs: The keyword arguments to configure the span with.
194    """
195    kwargs.setdefault('traceId', self.traceId)
196    kwargs.setdefault('labels', self.labels)
197    kwargs.setdefault('parentSpanId', self.last_span_id)
198    span = Span(name, **kwargs)
199    if self.traceId is None:
200      self.traceId = span.traceId
201    return span
202
203  @contextlib.contextmanager
204  def Span(self, name, **kwargs):
205    """Enter a new Span context contained within the top Span of the stack.
206
207    Args:
208      name: The name of the span to enter
209      **kwargs: The kwargs to construct the span with.
210
211    Side effect:
212      Appends the new span object to |spans|, and yields span while in its
213      context. Pops the span object when exiting the context.
214
215    Returns:
216      A contextmanager whose __enter__() returns the new Span.
217    """
218    span = self._CreateSpan(name, **kwargs)
219    old_span_id, self.last_span_id = self.last_span_id, span.spanId
220    self.spans.append(span)
221
222    with span:
223      with self.EnvironmentContext():
224        yield span
225
226    self.spans.pop()
227    self.last_span_id = old_span_id
228
229    # Log each span to a file for later processing.
230    if self.enabled:
231      LogSpan(span)
232
233  # pylint: disable=docstring-misnamed-args
234  def Spanned(self, *span_args, **span_kwargs):
235    """A decorator equivalent of 'with span_stack.Span(...)'
236
237    Args:
238      *span_args: *args to use with the .Span
239      **span_kwargs: **kwargs to use with the .Span
240
241    Returns:
242      A decorator to wrap the body of a function in a span block.
243    """
244    def SpannedDecorator(f):
245      """Wraps the body of |f| with a .Span block."""
246      @functools.wraps(f)
247      def inner(*args, **kwargs):
248        with self.Span(*span_args, **span_kwargs):
249          f(*args, **kwargs)
250      return inner
251    return SpannedDecorator
252
253  def _GetCloudTraceContextHeader(self):
254    """Gets the Cloud Trace HTTP header context.
255
256    From the cloud trace doc explaining this (
257    https://cloud.google.com/trace/docs/support?hl=bg)
258
259      'X-Cloud-Trace-Context: TRACE_ID/SPAN_ID;o=TRACE_TRUE'
260      Where:
261        - TRACE_ID is a 32-character hex value representing a 128-bit number.
262        It should be unique between your requests, unless you intentionally
263        want to bundle the requests together. You can use UUIDs.
264        - SPAN_ID should be 0 for the first span in your trace. For
265        subsequent requests, set SPAN_ID to the span ID of the parent
266        request. See the description of TraceSpan (REST, RPC) for more
267        information about nested traces.
268        - TRACE_TRUE must be 1 to trace this request. Specify 0 to not trace
269        the request. For example, to force a trace with cURL:
270          curl 'http://www.example.com' --header 'X-Cloud-Trace-Context:
271            105445aa7843bc8bf206b120001000/0;o=1'
272    """
273    if not self.traceId:
274      return ''
275    span_postfix = '/%s' % self.spans[-1].spanId if self.spans else ''
276    enabled = '1' if self.enabled else '0'
277    return '{trace_id}{span_postfix};o={enabled}'.format(
278        trace_id=self.traceId,
279        span_postfix=span_postfix,
280        enabled=enabled)
281
282  @contextlib.contextmanager
283  def EnvironmentContext(self):
284    """Sets CLOUD_TRACE_CONTEXT to the value of X-Cloud-Trace-Context.
285
286    Cloud Trace uses an HTTP header to propagate trace context across RPC
287    boundaries. This method does the same across process boundaries using an
288    environment variable.
289    """
290    old_value = os.environ.get(self.CLOUD_TRACE_CONTEXT_ENV)
291    try:
292      os.environ[self.CLOUD_TRACE_CONTEXT_ENV] = (
293          self._GetCloudTraceContextHeader())
294      yield
295    finally:
296      if old_value is not None:
297        os.environ[self.CLOUD_TRACE_CONTEXT_ENV] = old_value
298      elif self.CLOUD_TRACE_CONTEXT_ENV in os.environ:
299        del os.environ[self.CLOUD_TRACE_CONTEXT_ENV]
300
301  @staticmethod
302  def _ParseCloudTraceContext(context):
303    """Sets current_span_id and trace_id from the |context|.
304
305    See _GetCloudTraceContextHeader.
306
307    Args:
308      context: The context variable, either from X-Cloud-Trace-Context
309          or from the CLOUD_TRACE_CONTEXT environment variable.
310
311    Returns:
312      A dictionary, which if the context string matches
313      CLOUD_TRACE_CONTEXT_PATTERN, contains the matched groups. If not matched,
314      returns an empty dictionary.
315    """
316    m = SpanStack.CLOUD_TRACE_CONTEXT_PATTERN.match(context)
317    if m:
318      return m.groupdict()
319    else:
320      return {}