runbot/mergebot_test_utils/saas_tracing/__init__.py
Xavier Morel 98bb01edfc [ADD] runbot_merge, tests: opentelemetry support
Mostly for tests: it can be really difficult to correlate issues as
there are 3 different processes involved (the test runner, the odoo
being tested, and dummy-central (as github)) and the intermixing of
logs between the test driver and the odoo being tested is
not *amazing*.

- `pytest-opentelemetry`'s `--export-traces` is the signal for running
  tests with tracing enabled, that way just having
  `pytest-opentelemetry` installed does not do anything untowards.
- Until chrisguidry/pytest-opentelemetry#34 is merged, should probably
  use the linked branch as the default / base mode of having a single
  trace for the entire test suite is not great when there are 460
  tests, especially as local clients (opentelemetry-desktop-viewer,
  venator) mostly work on traces and aren't very good at zooming on
  *spans* at least currently.
- Additionally, the conftest plugin adds hooks for propagating through
  the xmlrpc client (communications with odoo) and enables the
  requests instrumentor from the opentelemetry python contribs.
- The dummy `saas_worker` was moved into the filesystem, that makes it
  easier to review and update.
- A second such module was added for the otel instrumentation *of the
  odoo under test*, that instruments psycopg2, requests, wsgi, and the
  server side of xmlrpc.
- The git ops were instrumented directly in runbot_merge, as I've
  tried to keep `saas_tracing` relatively generic, in case it could be
  moved to community or used by internal eventually.

Some typing was added to the conftest hooks and fixtures, and they
were migrated from tmpdir (py.path) to tmp_path (pathlib) for
consistency, even though technically the `mkdir` of pathlib is an
annoying downgrade.

Fixes #835
2025-02-24 15:43:11 +01:00

124 lines
5.0 KiB
Python

"""OpenTelemetry instrumentation
- tries to set up the exporting of traces, metrics, and logs based on otel
semantics
- automatically instruments psycopg2 and requests to trace outbound requests
- instruments db preload, server run to create new traces (or inbound from env)
- instruments WSGI, RPC for inbound traces
.. todo:: instrument crons
.. todo:: instrument subprocess (?)
"""
import functools
import json
import os
from opentelemetry import trace, distro
from opentelemetry.environment_variables import OTEL_LOGS_EXPORTER, OTEL_METRICS_EXPORTER, OTEL_TRACES_EXPORTER
from opentelemetry.instrumentation import psycopg2, requests, wsgi
from opentelemetry.propagate import extract
from opentelemetry.sdk.resources import SERVICE_NAME, SERVICE_VERSION
import odoo.release
import odoo.service
tracer = trace.get_tracer('odoo')
distro.OpenTelemetryDistro().configure()
configurator = distro.OpenTelemetryConfigurator()
conf = {
"resource_attributes": {
SERVICE_NAME: odoo.release.product_name,
SERVICE_VERSION: odoo.release.major_version,
},
# OpenTelemetryDistro only set up trace and metrics, and not well (uses setenv)
"trace_exporter_names": [e] if (e := os.environ.get(OTEL_LOGS_EXPORTER, "otlp")) else [],
"metric_exporter_names": [e] if (e := os.environ.get(OTEL_METRICS_EXPORTER, "otlp")) else [],
"log_exporter_names": [e] if (e := os.environ.get(OTEL_TRACES_EXPORTER, "otlp")) else [],
}
# open-telemetry/opentelemetry-pythhon#4340 changed the name (and some semantics)
# of the parameters so need to try new and fall back to old
try:
configurator.configure(setup_logging_handler=True, **conf)
except TypeError:
configurator.configure(logging_enabled=True, **conf)
# Breaks server instrumentation when enabled: threads inherit the init context
# instead of creating a per-request / per-job trace, if we want to propagate
# tracing through to one-short workers it should be done by hand (using
# extract/inject)
# ThreadingInstrumentor().instrument()
# adds otel trace/span information to the logrecord which is not what we care about
# LoggingInstrumentor().instrument()
psycopg2.Psycopg2Instrumentor().instrument(
# instrumenter checks for psycopg2, but dev machines may have
# psycopg2-binary, if not present odoo literally can't run so no need to
# check
skip_dep_check=True,
)
requests.RequestsInstrumentor().instrument()
# FIXME: blacklist /xmlrpc here so it's not duplicated by the instrumentation
# of execute below, the middleware currently does not support blacklisting
# (open-telemetry/opentelemetry-python-contrib#2369)
#
# FIXME:
# - servers which mount `odoo.http.root` (before patched?)
# - `lazy_property.reset_all(odoo.http.root)`
# - `patch('odoo.http.root.get_db_router', ...)`
odoo.http.root = wsgi.OpenTelemetryMiddleware(odoo.http.root)
# because there's code which accesses attributes on `odoo.http.root`
wsgi.OpenTelemetryMiddleware.__getattr__ = lambda self, attr: getattr(self.wsgi, attr)
def wraps(obj: object, attr: str):
""" Wraps the callable ``attr`` of ``obj`` in the decorated callable
in-place (so patches ``obj``).
The wrappee is passed as first argument to the wrapper.
"""
def decorator(fn):
wrapped = getattr(obj, attr)
@functools.wraps(wrapped)
def wrapper(*args, **kw):
return fn(wrapped, *args, **kw)
setattr(obj, attr, wrapper)
return decorator
# instrument / span the method call side of RPC calls so we don't just have an
# opaque "POST /xmlrpc/2"
@wraps(odoo.service.model, 'execute')
def instrumented_execute(wrapped_execute, db, uid, obj, method, *args, **kw):
with tracer.start_as_current_span( f"{obj}.{method}", attributes={
"db": db,
"uid": uid,
# while attributes can be sequences they can't be map, or nested
"args": json.dumps(args, default=str),
"kwargs": json.dumps(kw, default=str),
}):
return wrapped_execute(db, uid, obj, method, *args, **kw)
# Instrument the server & preload so we know / can trace what happens during
# init. Server instrumentation performs context extraction from environment
# (preload is just nested inside that).
@wraps(odoo.service.server.ThreadedServer, "run")
def instrumented_threaded_start(wrapped_threaded_run, self, preload=None, stop=None):
with tracer.start_as_current_span("server.threaded.run", context=extract(os.environ)):
return wrapped_threaded_run(self, preload=preload, stop=stop)
@wraps(odoo.service.server.PreforkServer, "run")
def instrumented_prefork_run(wrapped_prefork_run, self, preload=None, stop=None):
with tracer.start_as_current_span("server.prefork.run", context=extract(os.environ)):
return wrapped_prefork_run(self, preload=preload, stop=stop)
@wraps(odoo.service.server, "preload_registries")
def instrumented_preload(wrapped_preload, dbnames):
with tracer.start_as_current_span("preload", attributes={
"dbnames": dbnames,
}):
return wrapped_preload(dbnames)