From 8f532eec32545a9949c46c2ef0d4aa6f66ce6e27 Mon Sep 17 00:00:00 2001 From: Ryan Cole Date: Fri, 27 Feb 2026 22:59:04 +1300 Subject: [PATCH 1/2] First installable version --- otel/__init__.py | 1 + otel/__manifest__.py | 16 ++++ otel/bootstrap.py | 90 ++++++++++++++++++ otel/config.py | 209 ++++++++++++++++++++++++++++++++++++++++++ otel/pyproject.toml | 3 + otel/requirements.txt | 4 + 6 files changed, 323 insertions(+) create mode 100644 otel/__init__.py create mode 100644 otel/__manifest__.py create mode 100644 otel/bootstrap.py create mode 100644 otel/config.py create mode 100644 otel/pyproject.toml create mode 100644 otel/requirements.txt diff --git a/otel/__init__.py b/otel/__init__.py new file mode 100644 index 00000000000..3c74b703dc2 --- /dev/null +++ b/otel/__init__.py @@ -0,0 +1 @@ +from .bootstrap import init_otel diff --git a/otel/__manifest__.py b/otel/__manifest__.py new file mode 100644 index 00000000000..29a48d01cbb --- /dev/null +++ b/otel/__manifest__.py @@ -0,0 +1,16 @@ +# Copyright 2025 Ryan Cole (https://www.ryanc.me) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +{ + "name": "OpenTelemetry", + "summary": "Fully featured OpenTelemetry integration for Odoo", + "author": "Ryan Cole, Odoo Community Association (OCA)", + "maintainers": ["ryanc-me"], + "category": "Technical", + "website": "https://github.com/OCA/server-tools", + "version": "18.0.1.0.0", + "license": "LGPL-3", + "installable": True, + "depends": ["base"], + "post_load": "init_otel", +} \ No newline at end of file diff --git a/otel/bootstrap.py b/otel/bootstrap.py new file mode 100644 index 00000000000..d3e43fc44a6 --- /dev/null +++ b/otel/bootstrap.py @@ -0,0 +1,90 @@ +from .config import OTelConfig + +from opentelemetry import trace +from opentelemetry.sdk.resources import Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor, SimpleSpanProcessor +import logging + +_logger = logging.getLogger(__name__) + + +def _build_resource(resource_attributes: dict) -> Resource: + return Resource(attributes=resource_attributes) + + +def _init_tracing(config: OTelConfig): + if not config.traces_exporter: + _logger.info("OpenTelemetry tracing is not configured, skipping") + return + + resource = _build_resource(config.resource_attributes) + provider = TracerProvider(resource=resource) + + if config.traces_exporter.protocol == "grpc": + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter, + ) + + exporter = OTLPSpanExporter( + endpoint=config.traces_exporter.endpoint, + headers=config.traces_exporter.headers, + insecure=config.traces_exporter.grpc_insecure, + ) + provider.add_span_processor(BatchSpanProcessor(exporter)) + elif config.traces_exporter.protocol == "http/protobuf": + from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter, + ) + + exporter = OTLPSpanExporter( + endpoint=config.traces_exporter.endpoint, + headers=config.traces_exporter.headers, + ) + provider.add_span_processor(SimpleSpanProcessor(exporter)) + else: + _logger.error( + f"Invalid traces exporter protocol: {config.traces_exporter.protocol}" + ) + return + + trace.set_tracer_provider(provider) + _logger.info("OpenTelemetry tracing initialized") + + +def _init_metrics(config: OTelConfig): + if not config.metrics_exporter: + _logger.info("OpenTelemetry metrics export is not configured, skipping") + return + + _logger.warning( + "OpenTelemetry metrics export is configured but not implemented yet" + ) + + +def _init_logs(config: OTelConfig): + if not config.logs_exporter: + _logger.info("OpenTelemetry logs export is not configured, skipping") + return + + _logger.warning("OpenTelemetry logs export is configured but not implemented yet") + + +_OTEL_INITIALIZED = False + + +def init_otel(): + global _OTEL_INITIALIZED + if _OTEL_INITIALIZED: + return + + config = OTelConfig.load() + if not config.enable: + _logger.info("OpenTelemetry is disabled by configuration") + return + + _init_tracing(config) + _init_metrics(config) + _init_logs(config) + + _OTEL_INITIALIZED = True diff --git a/otel/config.py b/otel/config.py new file mode 100644 index 00000000000..a7853237e4d --- /dev/null +++ b/otel/config.py @@ -0,0 +1,209 @@ +from odoo.tools import config as odoo_config +from dataclasses import dataclass +from typing import Dict, Optional +import os + +import logging + +_logger = logging.getLogger(__name__) + + +PROTO_GRPC_AVAILABLE = False +try: + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter as OTLPSpanExporterGRPC, + ) + from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( + OTLPMetricExporter as OTLPMetricExporterGRPC, + ) + from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( + OTLPLogExporter as OTLPLogExporterGRPC, + ) + + PROTO_GRPC_AVAILABLE = True +except Exception: + pass + +PROTO_HTTP_AVAILABLE = False +try: + from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter as OTLPSpanExporterHTTP, + ) + from opentelemetry.exporter.otlp.proto.http.metric_exporter import ( + OTLPMetricExporter as OTLPMetricExporterHTTP, + ) + from opentelemetry.exporter.otlp.proto.http._log_exporter import ( + OTLPLogExporter as OTLPLogExporterHTTP, + ) + + PROTO_HTTP_AVAILABLE = True +except Exception: + pass + + +def _parse_keyvals(keyvals_str: Optional[str]) -> Dict[str, str]: + """Helper to parse key=value,key=value strings into a dict""" + keyvals = {} + if keyvals_str: + for keyval in keyvals_str.split(","): + if "=" not in keyval: + _logger.warning(f"Invalid key=value pair: {keyval}") + continue + key, value = keyval.split("=", 1) + if key and value: + keyvals[key.strip()] = value.strip() + return keyvals + + +def _normalise_protocol(protocol: str) -> Optional[str]: + """Map protocol options to standard values""" + if protocol == "grpc": + return "grpc" + if protocol in ("http/protobuf", "http/proto", "http"): + return "http/protobuf" + _logger.warning(f"Unsupported protocol: {protocol}") + return None + + +def _get_config(key: str, default: Optional[str] = None) -> Optional[str]: + """Helper to get config values, checking env first, then Odoo config""" + key_env = key + key_conf = key[5:].lower() # strip OTEL_ and lower + otel_config = odoo_config.misc.get("otel", {}) + return os.getenv(key_env) or otel_config.get(key_conf) or default + + +@dataclass(frozen=True) +class OTelExporterConfig: + protocol: str + endpoint: str + headers: Dict[str, str] + grpc_insecure: Optional[bool] = None + + @staticmethod + def load( + signal: str, + default_protocol: str, + default_endpoint_http: str, + default_endpoint_grpc: str, + default_headers: Dict[str, str], + ) -> Optional["OTelExporterConfig"]: + sig = signal.upper() + + enable = _get_config(f"OTEL_EXPORTER_OTLP_{sig}_ENABLE", False) + if not enable: + return None + + proto = _normalise_protocol( + _get_config(f"OTEL_EXPORTER_OTLP_{sig}_PROTOCOL", default_protocol) + ) + if proto == "grpc" and not PROTO_GRPC_AVAILABLE: + _logger.error( + f"gRPC selected for {signal} but dependency is not installed. Hint:\n - pip install opentelemetry-exporter-otlp-proto-grpc" + ) + return None + + if proto == "grpc": + default_endpoint = default_endpoint_grpc + elif proto == "http/protobuf": + default_endpoint = default_endpoint_http + else: + _logger.error(f"Unsupported protocol for {signal}: {proto}") + return None + + endpoint = _get_config( + f"OTEL_EXPORTER_OTLP_{sig}_ENDPOINT", + default_endpoint, + ) + headers = ( + _parse_keyvals( + _get_config( + f"OTEL_EXPORTER_OTLP_{sig}_HEADERS", + None, + ) + ) + or default_headers + ) + + grpc_insecure = False + if proto == "grpc": + grpc_insecure = endpoint.startswith("http://") + + return OTelExporterConfig( + protocol=proto, + endpoint=endpoint, + headers=headers, + grpc_insecure=grpc_insecure, + ) + + +@dataclass(frozen=True) +class OTelConfig: + enable: bool + resource_attributes: Dict[str, str] + traces_exporter: Optional[OTelExporterConfig] + logs_exporter: Optional[OTelExporterConfig] + metrics_exporter: Optional[OTelExporterConfig] + + @staticmethod + def disabled() -> "OTelConfig": + return OTelConfig( + enable=False, + resource_attributes={}, + traces_exporter=None, + logs_exporter=None, + metrics_exporter=None, + ) + + @staticmethod + def load() -> "OTelConfig": + if not PROTO_GRPC_AVAILABLE and not PROTO_HTTP_AVAILABLE: + _logger.error( + "No OTLP exporter available. Hint:\n" + " - pip install opentelemetry-exporter-otlp-proto-grpc\n" + " - pip install opentelemetry-exporter-otlp-proto-http" + ) + return OTelConfig.disabled() + + enable = _get_config("OTEL_ENABLE", False) + resource_attrs = _parse_keyvals(_get_config("OTEL_RESOURCE_ATTRIBUTES", "")) + default_protocol = _normalise_protocol( + _get_config("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") + ) + default_endpoint_http = _get_config( + "OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318" + ) + default_endpoint_grpc = _get_config( + "OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4317" + ) + default_headers = _parse_keyvals(_get_config("OTEL_EXPORTER_OTLP_HEADERS", "")) + + metrics_exporter = OTelExporterConfig.load( + "METRICS", + default_protocol, + default_endpoint_http, + default_endpoint_grpc, + default_headers, + ) + logs_exporter = OTelExporterConfig.load( + "LOGS", + default_protocol, + default_endpoint_http, + default_endpoint_grpc, + default_headers, + ) + traces_exporter = OTelExporterConfig.load( + "TRACES", + default_protocol, + default_endpoint_http, + default_endpoint_grpc, + default_headers, + ) + + return OTelConfig( + enable=enable, + resource_attributes=resource_attrs, + traces_exporter=traces_exporter, + logs_exporter=logs_exporter, + metrics_exporter=metrics_exporter, + ) diff --git a/otel/pyproject.toml b/otel/pyproject.toml new file mode 100644 index 00000000000..1c45ffcf658 --- /dev/null +++ b/otel/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" \ No newline at end of file diff --git a/otel/requirements.txt b/otel/requirements.txt new file mode 100644 index 00000000000..9b16105bd38 --- /dev/null +++ b/otel/requirements.txt @@ -0,0 +1,4 @@ +opentelemetry-api>=1.39,<2 +opentelemetry-sdk>=1.39,<2 +opentelemetry-exporter-otlp-proto-grpc>=1.39,<2 +opentelemetry-exporter-otlp-proto-http>=1.39,<2 From 40d66cf84c2ecfa08de24ff72b03647fe77c7719 Mon Sep 17 00:00:00 2001 From: Ryan Cole Date: Wed, 18 Mar 2026 21:14:23 +1300 Subject: [PATCH 2/2] First working version --- otel/README.rst | 190 +++++++++ otel/TODO.md | 9 + otel/__init__.py | 3 +- otel/__manifest__.py | 9 +- otel/bootstrap.py | 20 +- otel/config.py | 76 ++-- otel/controllers/__init__.py | 1 + otel/controllers/web.py | 146 +++++++ otel/models.py | 642 +++++++++++++++++++++++++++++ otel/post_load.py | 7 + otel/pyproject.toml | 2 +- otel/readme/CONFIGURE.md | 70 ++++ otel/readme/CONTRIBUTORS.md | 1 + otel/readme/DESCRIPTION.md | 3 + otel/readme/INSTALL.md | 16 + otel/requirements.txt | 1 + otel/static/description/index.html | 534 ++++++++++++++++++++++++ otel/utils.py | 27 ++ otel_web/DESIGN.md | 162 ++++++++ otel_web/__init__.py | 0 otel_web/__manifest__.py | 15 + otel_web/pyproject.toml | 3 + 22 files changed, 1898 insertions(+), 39 deletions(-) create mode 100644 otel/README.rst create mode 100644 otel/TODO.md create mode 100644 otel/controllers/__init__.py create mode 100644 otel/controllers/web.py create mode 100644 otel/models.py create mode 100644 otel/post_load.py create mode 100644 otel/readme/CONFIGURE.md create mode 100644 otel/readme/CONTRIBUTORS.md create mode 100644 otel/readme/DESCRIPTION.md create mode 100644 otel/readme/INSTALL.md create mode 100644 otel/static/description/index.html create mode 100644 otel/utils.py create mode 100644 otel_web/DESIGN.md create mode 100644 otel_web/__init__.py create mode 100644 otel_web/__manifest__.py create mode 100644 otel_web/pyproject.toml diff --git a/otel/README.rst b/otel/README.rst new file mode 100644 index 00000000000..a1cac247aee --- /dev/null +++ b/otel/README.rst @@ -0,0 +1,190 @@ +============= +OpenTelemetry +============= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:e51d09d42e4c3a9c8d2991835625afd971653978ff13aeac6aa7a4ac683499c6 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--tools-lightgray.png?logo=github + :target: https://github.com/OCA/server-tools/tree/18.0/otel + :alt: OCA/server-tools +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-tools-18-0/server-tools-18-0-otel + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-tools&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module instruments Odoo with OpenTelemetry support, including +``traceparent`` support for distributed tracing and correlation. It also +includes optional support for instrumenting PostgreSQL (via psycopg2). + +**Table of contents** + +.. contents:: + :local: + +Installation +============ + +This module should be added to your addons directory just like any other +module. You should then add the module to your ``server_wide_modules``, +e.g.: + +.. code:: ini + + # odoo-server.conf + + [options] + # ... + server_wide_modules = base,web,otel + +You will also need to install the Python dependencies via pip: + +.. code:: shell + + pip3 install -r /path/to/otel/requirements.txt + +Configuration +============= + +The module aims to accomodate different use-cases, and as such, there +are quite a few configuration options. Generally, only a few basic +options are needed for most deployments. + +Minimum Config +============== + +For most basic use-cases, the following config will be sufficient. + +*Note*: This assumes a collector is running locally, and accepts +``http/protobuf`` requests. + +.. code:: ini + + # odoo-server.conf + [options] + # ... + + [otel] + # enable the module + enable = True + + # these vars will be tacked onto *all* traces; you should set `service.name` at least + resource_attributes = service.name=odoo,odoo.version=18.0,deployment.environment=dev + + # your OTLP endpoint + exporter_otlp_endpoint = http://localhost:4318/v1/traces + + # you may also need to set this, if your collector wants gRPC + # exporter_otlp_protocol = grpc + +Configuration options can be passed either by environment variables, or +set in the Odoo conf file. Environment variables take precedence. Option +names are consistent between these two options. To convert from env-var +toconf, simply strip the leading ``OTEL_``, and make lowercase. For +example: + +- ``OTEL_ENABLE`` -> ``enable`` +- ``OTEL_EXPORTER_OTLP_ENDPOINT`` -> ``exporter_otlp_endpoint`` +- etc + +Config Reference +================ + +Core Options +------------ + +- ``OTEL_ENABLE`` will enable (or disable) the module. Possible values: + ``true`` or ``false`` +- ``OTEL_RESOURCE_ATTRIBUTES`` should be a set of + ``key1=value,key2=value`` pairs. You should set ``service.name`` at + least, and consider setting ``deployment.environment`` +- ``OTEL_RESOURCE_ATTRIBUTES_SERVICE_NAME`` will override the + ``service.name`` attribute in the Resource Attributes +- ``OTEL_RESOURCE_ATTRIBUTES_SERVICE_VERSION`` as above, but for the + ``service.version`` attribute; useful if you want to set the version + dynamically (e.g., to a docker build hash, or git revision) +- ``OTEL_RESOURCE_ATTRIBUTES_DEPLOYMENT_ENVIRONMENT`` as above, but for + the ``deployment.environment`` attribute (again, useful to be + configureable dynamically) + +Collector Options +----------------- + +- ``OTEL_EXPORTER_OTLP_PROTOCOL`` is the protocol the OTel SDK will use + to communicate with your collector. Possible values: ``grpc`` or + ``http`` (which is an alias for ``http/protobuf``) +- ``OTEL_EXPORTER_OTLP_ENDPOINT`` is the collector endpoint. Examples + are: + + - ``http://localhost:4317`` (gRPC) + - ``http://localhost:4318`` (http/protobuf) + +- ``OTEL_EXPORTER_OTLP_HEADERS`` accepts a set of + ``header=value,header2=value`` pairs, which will be sent along with + requests to the collector. This is useful for passing auth headers. + Examples are: + + - ``exporter_otlp_headers = Authorization=Bearer 12345`` + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Ryan Cole + +Contributors +------------ + +- Ryan Cole + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-ryanc-me| image:: https://github.com/ryanc-me.png?size=40px + :target: https://github.com/ryanc-me + :alt: ryanc-me + +Current `maintainer `__: + +|maintainer-ryanc-me| + +This module is part of the `OCA/server-tools `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/otel/TODO.md b/otel/TODO.md new file mode 100644 index 00000000000..73876d87bad --- /dev/null +++ b/otel/TODO.md @@ -0,0 +1,9 @@ + - Cron tracing + - Add span/trace IDs to logs (https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/logging/logging.html) + - Allow enable/disable tracing per level (RPC, ORM, Cron, DB) + - For each type, a few options: + - Allowlist for methods (empty for all) + - Allow excluding particular models (ir.model.access, etc) + - Allow excluding model:method pairs? + - Only trace when parent is tracing (possible for DB?) + - PII handling \ No newline at end of file diff --git a/otel/__init__.py b/otel/__init__.py index 3c74b703dc2..f725a57155c 100644 --- a/otel/__init__.py +++ b/otel/__init__.py @@ -1 +1,2 @@ -from .bootstrap import init_otel +from .post_load import post_load +from . import controllers diff --git a/otel/__manifest__.py b/otel/__manifest__.py index 29a48d01cbb..1c9c5a11b68 100644 --- a/otel/__manifest__.py +++ b/otel/__manifest__.py @@ -1,4 +1,4 @@ -# Copyright 2025 Ryan Cole (https://www.ryanc.me) +# Copyright 2026 Ryan Cole (https://www.ryanc.me) # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). { @@ -11,6 +11,7 @@ "version": "18.0.1.0.0", "license": "LGPL-3", "installable": True, - "depends": ["base"], - "post_load": "init_otel", -} \ No newline at end of file + "depends": ["web"], + "post_load": "post_load", + "sequence": 999, +} diff --git a/otel/bootstrap.py b/otel/bootstrap.py index d3e43fc44a6..928313265d7 100644 --- a/otel/bootstrap.py +++ b/otel/bootstrap.py @@ -1,10 +1,11 @@ -from .config import OTelConfig +import logging from opentelemetry import trace from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor, SimpleSpanProcessor -import logging + +from .config import OTelConfig _logger = logging.getLogger(__name__) @@ -15,7 +16,7 @@ def _build_resource(resource_attributes: dict) -> Resource: def _init_tracing(config: OTelConfig): if not config.traces_exporter: - _logger.info("OpenTelemetry tracing is not configured, skipping") + # _logger.info("OpenTelemetry tracing is not configured, skipping") return resource = _build_resource(config.resource_attributes) @@ -54,7 +55,7 @@ def _init_tracing(config: OTelConfig): def _init_metrics(config: OTelConfig): if not config.metrics_exporter: - _logger.info("OpenTelemetry metrics export is not configured, skipping") + # _logger.info("OpenTelemetry metrics export is not configured, skipping") return _logger.warning( @@ -64,12 +65,18 @@ def _init_metrics(config: OTelConfig): def _init_logs(config: OTelConfig): if not config.logs_exporter: - _logger.info("OpenTelemetry logs export is not configured, skipping") + # _logger.info("OpenTelemetry logs export is not configured, skipping") return _logger.warning("OpenTelemetry logs export is configured but not implemented yet") +def _init_psycopg2(): + from opentelemetry.instrumentation.psycopg2 import Psycopg2Instrumentor + + Psycopg2Instrumentor().instrument() + + _OTEL_INITIALIZED = False @@ -86,5 +93,8 @@ def init_otel(): _init_tracing(config) _init_metrics(config) _init_logs(config) + _init_psycopg2() _OTEL_INITIALIZED = True + + _logger.info("OpenTelemetry initialized") diff --git a/otel/config.py b/otel/config.py index a7853237e4d..f5d07c7cb28 100644 --- a/otel/config.py +++ b/otel/config.py @@ -1,47 +1,49 @@ -from odoo.tools import config as odoo_config -from dataclasses import dataclass -from typing import Dict, Optional +import logging import os +from dataclasses import dataclass +from typing import Optional -import logging +from odoo.tools import config as odoo_config _logger = logging.getLogger(__name__) PROTO_GRPC_AVAILABLE = False try: - from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( - OTLPSpanExporter as OTLPSpanExporterGRPC, + from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( + OTLPLogExporter as OTLPLogExporterGRPC, # noqa: F401 ) from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( - OTLPMetricExporter as OTLPMetricExporterGRPC, + OTLPMetricExporter as OTLPMetricExporterGRPC, # noqa: F401 ) - from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( - OTLPLogExporter as OTLPLogExporterGRPC, + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter as OTLPSpanExporterGRPC, # noqa: F401 ) PROTO_GRPC_AVAILABLE = True except Exception: + _logger.info("gRPC OTLP exporter not available, gRPC support disabled") pass PROTO_HTTP_AVAILABLE = False try: - from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( - OTLPSpanExporter as OTLPSpanExporterHTTP, + from opentelemetry.exporter.otlp.proto.http._log_exporter import ( + OTLPLogExporter as OTLPLogExporterHTTP, # noqa: F401 ) from opentelemetry.exporter.otlp.proto.http.metric_exporter import ( - OTLPMetricExporter as OTLPMetricExporterHTTP, + OTLPMetricExporter as OTLPMetricExporterHTTP, # noqa: F401 ) - from opentelemetry.exporter.otlp.proto.http._log_exporter import ( - OTLPLogExporter as OTLPLogExporterHTTP, + from opentelemetry.exporter.otlp.proto.http.trace_exporter import ( + OTLPSpanExporter as OTLPSpanExporterHTTP, # noqa: F401 ) PROTO_HTTP_AVAILABLE = True except Exception: + _logger.info("HTTP OTLP exporter not available, HTTP support disabled") pass -def _parse_keyvals(keyvals_str: Optional[str]) -> Dict[str, str]: +def _parse_keyvals(keyvals_str: str | None) -> dict[str, str]: """Helper to parse key=value,key=value strings into a dict""" keyvals = {} if keyvals_str: @@ -55,7 +57,7 @@ def _parse_keyvals(keyvals_str: Optional[str]) -> Dict[str, str]: return keyvals -def _normalise_protocol(protocol: str) -> Optional[str]: +def _normalise_protocol(protocol: str) -> str | None: """Map protocol options to standard values""" if protocol == "grpc": return "grpc" @@ -65,7 +67,7 @@ def _normalise_protocol(protocol: str) -> Optional[str]: return None -def _get_config(key: str, default: Optional[str] = None) -> Optional[str]: +def _get_config(key: str, default: str | None = None) -> str | None: """Helper to get config values, checking env first, then Odoo config""" key_env = key key_conf = key[5:].lower() # strip OTEL_ and lower @@ -77,8 +79,8 @@ def _get_config(key: str, default: Optional[str] = None) -> Optional[str]: class OTelExporterConfig: protocol: str endpoint: str - headers: Dict[str, str] - grpc_insecure: Optional[bool] = None + headers: dict[str, str] + grpc_insecure: bool | None = None @staticmethod def load( @@ -86,11 +88,11 @@ def load( default_protocol: str, default_endpoint_http: str, default_endpoint_grpc: str, - default_headers: Dict[str, str], + default_headers: dict[str, str], ) -> Optional["OTelExporterConfig"]: sig = signal.upper() - enable = _get_config(f"OTEL_EXPORTER_OTLP_{sig}_ENABLE", False) + enable = _get_config(f"OTEL_EXPORTER_OTLP_{sig}_ENABLE", True) if not enable: return None @@ -99,7 +101,8 @@ def load( ) if proto == "grpc" and not PROTO_GRPC_AVAILABLE: _logger.error( - f"gRPC selected for {signal} but dependency is not installed. Hint:\n - pip install opentelemetry-exporter-otlp-proto-grpc" + f"gRPC selected for {signal} but dependency is not installed. Hint:\n" + "- pip install opentelemetry-exporter-otlp-proto-grpc" ) return None @@ -139,11 +142,13 @@ def load( @dataclass(frozen=True) class OTelConfig: + # TODO: OTEL_SPAN_PROCESSOR (span/simple - let the user configure it) + enable: bool - resource_attributes: Dict[str, str] - traces_exporter: Optional[OTelExporterConfig] - logs_exporter: Optional[OTelExporterConfig] - metrics_exporter: Optional[OTelExporterConfig] + resource_attributes: dict[str, str] + traces_exporter: OTelExporterConfig | None + logs_exporter: OTelExporterConfig | None + metrics_exporter: OTelExporterConfig | None @staticmethod def disabled() -> "OTelConfig": @@ -166,7 +171,6 @@ def load() -> "OTelConfig": return OTelConfig.disabled() enable = _get_config("OTEL_ENABLE", False) - resource_attrs = _parse_keyvals(_get_config("OTEL_RESOURCE_ATTRIBUTES", "")) default_protocol = _normalise_protocol( _get_config("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") ) @@ -202,8 +206,24 @@ def load() -> "OTelConfig": return OTelConfig( enable=enable, - resource_attributes=resource_attrs, + resource_attributes=OTelConfig.load_resource_attributes(), traces_exporter=traces_exporter, logs_exporter=logs_exporter, metrics_exporter=metrics_exporter, ) + + @staticmethod + def load_resource_attributes() -> dict[str, str]: + attributes = _parse_keyvals(_get_config("OTEL_RESOURCE_ATTRIBUTES", "")) + service_name = _get_config("OTEL_RESOURCE_ATTRIBUTES_SERVICE_NAME", "") + if service_name: + attributes["service.name"] = service_name + service_version = _get_config("OTEL_RESOURCE_ATTRIBUTES_SERVICE_VERSION", "") + if service_version: + attributes["service.version"] = service_version + deployment_environment = _get_config( + "OTEL_RESOURCE_ATTRIBUTES_DEPLOYMENT_ENVIRONMENT", "" + ) + if deployment_environment: + attributes["deployment.environment"] = deployment_environment + return attributes diff --git a/otel/controllers/__init__.py b/otel/controllers/__init__.py new file mode 100644 index 00000000000..d6194da2b7c --- /dev/null +++ b/otel/controllers/__init__.py @@ -0,0 +1 @@ +from . import web diff --git a/otel/controllers/web.py b/otel/controllers/web.py new file mode 100644 index 00000000000..662f9cf0ac3 --- /dev/null +++ b/otel/controllers/web.py @@ -0,0 +1,146 @@ +import json +import logging +import os + +from opentelemetry import trace +from opentelemetry.trace import Status, StatusCode + +from odoo import http +from odoo.http import request + +from odoo.addons.web.controllers.action import Action +from odoo.addons.web.controllers.dataset import DataSet +from odoo.addons.web.controllers.report import ReportController + +from .. import bootstrap, utils + +_logger = logging.getLogger(__name__) + + +def trace_common(span): + try: + user_name = request.env["res.users"].browse(request.uid).name + except Exception: + user_name = "unknown" + span.set_attribute("http.route", request.httprequest.path) + span.set_attribute("http.method", request.httprequest.method) + span.set_attribute("odoo.db", request.db) + span.set_attribute("odoo.user_id", request.uid) + span.set_attribute("odoo.user_name", user_name) # PII + span.set_attribute("enduser.id", request.uid) + span.set_attribute("odoo.pid", os.getpid()) + + +class _Action(Action): + @http.route() + def load(self, action_id, context=None): + if not bootstrap._OTEL_INITIALIZED: + return super().load(action_id, context=context) + tracer = trace.get_tracer("odoo.otel.web") + pctx = utils.extract_context() + with tracer.start_as_current_span("action.load", context=pctx) as span: + Actions_sudo = request.env["ir.actions.actions"].sudo() + try: + action = Actions_sudo.browse(int(action_id)) + except ValueError: + try: + if "." in action_id: + action = request.env.ref(action_id) + else: + action = Actions_sudo.search( + [("path", "=", action_id)], limit=1 + ) + except Exception: + action = False + + trace_common(span) + span.set_attribute("odoo.action_id", action_id) + span.set_attribute("odoo.action_name", action.name if action else "unknown") + + try: + res = super().load(action_id, context=context) + span.set_status(Status(StatusCode.OK)) + return res + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise + + +class _DataSet(DataSet): + @http.route() + def call_kw(self, model, method, args, kwargs): + if not bootstrap._OTEL_INITIALIZED: + return super().call_kw(model, method, args, kwargs) + tracer = trace.get_tracer("odoo.otel.web") + pctx = utils.extract_context() + with tracer.start_as_current_span("dataset.call_kw", context=pctx) as span: + trace_common(span) + span.set_attribute("odoo.model", model) + span.set_attribute("odoo.method", method) + + try: + res = super().call_kw(model, method, args, kwargs) + span.set_status(Status(StatusCode.OK)) + return res + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise + + @http.route() + def call_button(self, model, method, args, kwargs, path=None): + if not bootstrap._OTEL_INITIALIZED: + return super().call_button(model, method, args, kwargs, path=path) + tracer = trace.get_tracer("odoo.otel.web") + pctx = utils.extract_context() + with tracer.start_as_current_span("dataset.call_button", context=pctx) as span: + trace_common(span) + span.set_attribute("odoo.model", model) + span.set_attribute("odoo.method", method) + + try: + res = super().call_button(model, method, args, kwargs, path=path) + span.set_status(Status(StatusCode.OK)) + return res + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise + + +class _ReportController(ReportController): + @http.route() + def report_download(self, data, context=None, token=None, readonly=True): + if not bootstrap._OTEL_INITIALIZED: + return super().report_download( + data, context=context, token=token, readonly=readonly + ) + tracer = trace.get_tracer("odoo.otel.web") + pctx = utils.extract_context() + with tracer.start_as_current_span("report.download", context=pctx) as span: + requestcontent = json.loads(data) + url, type_ = requestcontent[0], requestcontent[1] + pattern = "/report/pdf/" if type_ == "qweb-pdf" else "/report/text/" + reportname = url.split(pattern)[1].split("?")[0] + docids = None + if "/" in reportname: + reportname, docids = reportname.split("/") + + trace_common(span) + span.set_attribute("odoo.report_type", type_) + span.set_attribute("odoo.report_url", url) # maybe PII + span.set_attribute("odoo.report_name", reportname) + if docids: + span.set_attribute("odoo.report_docids", docids) + + try: + res = super().report_download( + data, context=context, token=token, readonly=readonly + ) + span.set_status(Status(StatusCode.OK)) + return res + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise diff --git a/otel/models.py b/otel/models.py new file mode 100644 index 00000000000..5f10780a7fa --- /dev/null +++ b/otel/models.py @@ -0,0 +1,642 @@ +import os + +from opentelemetry import trace +from opentelemetry.trace import SpanKind, Status, StatusCode + +from odoo import api, models + +from odoo.addons.web.models.models import Base as WebModel + +from . import bootstrap + + +def trace_common(span, records): + span.set_attribute("odoo.db", records.env.cr.dbname) + span.set_attribute("odoo.user_id", records.env.uid) + span.set_attribute("odoo.user_name", records.env.user.name) # PII + span.set_attribute("enduser.id", records.env.uid) + span.set_attribute("odoo.pid", os.getpid()) + + +METHODS_TO_PATCH = set() + + +def _patch_model(model, method_name, method): + global METHODS_TO_PATCH + METHODS_TO_PATCH.add((model, method_name, method)) + + +@api.model_create_multi +def create(self, vals_list): + if not bootstrap._OTEL_INITIALIZED: + return _model_create(self, vals_list) + tracer = trace.get_tracer("odoo.otel.models") + with tracer.start_as_current_span("model.create", kind=SpanKind.INTERNAL) as span: + trace_common(span, self) + span.set_attribute("odoo.model", self._name) + + try: + res = _model_create(self, vals_list) + span.set_attribute("odoo.res_ids", str(res.ids)) # maybe truncate? + span.set_status(Status(StatusCode.OK)) + return res + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise + + +_model_create = models.Model.create +_patch_model(models.Model, "create", create) + + +def write(self, vals): + if not bootstrap._OTEL_INITIALIZED: + return _model_write(self, vals) + tracer = trace.get_tracer("odoo.otel.models") + with tracer.start_as_current_span("model.write", kind=SpanKind.INTERNAL) as span: + trace_common(span, self) + span.set_attribute("odoo.model", self._name) + span.set_attribute("odoo.res_ids", str(self.ids)) # maybe truncate? + + try: + res = _model_write(self, vals) + span.set_status(Status(StatusCode.OK)) + return res + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise + + +_model_write = models.Model.write +_patch_model(models.Model, "write", write) + + +def read(self, fields=None, load="_classic_read"): + if not bootstrap._OTEL_INITIALIZED: + return _model_read(self, fields=fields, load=load) + tracer = trace.get_tracer("odoo.otel.models") + with tracer.start_as_current_span("model.read", kind=SpanKind.INTERNAL) as span: + trace_common(span, self) + span.set_attribute("odoo.model", self._name) + span.set_attribute("odoo.fields", str(fields)) + span.set_attribute("odoo.res_ids", str(self.ids)) # maybe truncate? + + try: + res = _model_read(self, fields=fields, load=load) + span.set_status(Status(StatusCode.OK)) + return res + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise + + +_model_read = models.Model.read +_patch_model(models.Model, "read", read) + + +def unlink(self): + if not bootstrap._OTEL_INITIALIZED: + return _model_unlink(self) + tracer = trace.get_tracer("odoo.otel.models") + with tracer.start_as_current_span("model.unlink", kind=SpanKind.INTERNAL) as span: + trace_common(span, self) + span.set_attribute("odoo.model", self._name) + span.set_attribute("odoo.res_ids", str(self.ids)) # maybe truncate? + + try: + res = _model_unlink(self) + span.set_status(Status(StatusCode.OK)) + return res + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise + + +_model_unlink = models.Model.unlink +_patch_model(models.Model, "unlink", unlink) + + +# web CRUD methods + + +@api.model +@api.readonly +def web_search_read( + self, domain, specification, offset=0, limit=None, order=None, count_limit=None +): + if not bootstrap._OTEL_INITIALIZED: + return _model_web_search_read( + self, + domain, + specification, + offset=offset, + limit=limit, + order=order, + count_limit=count_limit, + ) + tracer = trace.get_tracer("odoo.otel.models") + with tracer.start_as_current_span( + "model.web_search_read", kind=SpanKind.INTERNAL + ) as span: + trace_common(span, self) + span.set_attribute("odoo.model", self._name) + span.set_attribute("odoo.domain", str(domain)) + span.set_attribute("odoo.specification", str(specification)) # maybe truncate? + span.set_attribute("odoo.offset", offset) + if limit: + span.set_attribute("odoo.limit", limit) + if order: + span.set_attribute("odoo.order", order) + if count_limit: + span.set_attribute("odoo.count_limit", count_limit) + + try: + res = _model_web_search_read( + self, + domain, + specification, + offset=offset, + limit=limit, + order=order, + count_limit=count_limit, + ) + span.set_attribute("odoo.result_count", len(res)) + span.set_status(Status(StatusCode.OK)) + return res + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise + + +_model_web_search_read = WebModel.web_search_read +_patch_model(WebModel, "web_search_read", web_search_read) + + +def web_save(self, vals, specification, next_id=None): + if not bootstrap._OTEL_INITIALIZED: + return _model_web_save(self, vals, specification, next_id=next_id) + tracer = trace.get_tracer("odoo.otel.models") + with tracer.start_as_current_span("model.web_save", kind=SpanKind.INTERNAL) as span: + trace_common(span, self) + span.set_attribute("odoo.model", self._name) + span.set_attribute("odoo.vals", str(vals)) # maybe truncate? + span.set_attribute("odoo.specification", str(specification)) # maybe truncate? + if next_id: + span.set_attribute("odoo.next_id", next_id) + + try: + res = _model_web_save(self, vals, specification, next_id=next_id) + span.set_status(Status(StatusCode.OK)) + return res + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise + + +_model_web_save = WebModel.web_save +_patch_model(WebModel, "web_save", web_save) + + +@api.readonly +def web_read(self, specification): + if not bootstrap._OTEL_INITIALIZED: + return _model_web_read(self, specification) + tracer = trace.get_tracer("odoo.otel.models") + with tracer.start_as_current_span("model.web_read", kind=SpanKind.INTERNAL) as span: + trace_common(span, self) + span.set_attribute("odoo.model", self._name) + span.set_attribute("odoo.specification", str(specification)) # maybe truncate? + + try: + res = _model_web_read(self, specification) + span.set_status(Status(StatusCode.OK)) + return res + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise + + +_model_web_read = WebModel.web_read +_patch_model(WebModel, "web_read", web_read) + + +@api.model +@api.readonly +def web_read_group( + self, domain, fields, groupby, limit=None, offset=0, orderby=False, lazy=True +): + if not bootstrap._OTEL_INITIALIZED: + return _model_web_read_group( + self, + domain, + fields, + groupby, + limit=limit, + offset=offset, + orderby=orderby, + lazy=lazy, + ) + tracer = trace.get_tracer("odoo.otel.models") + with tracer.start_as_current_span( + "model.web_read_group", kind=SpanKind.INTERNAL + ) as span: + trace_common(span, self) + span.set_attribute("odoo.model", self._name) + span.set_attribute("odoo.domain", str(domain)) + span.set_attribute("odoo.fields", str(fields)) # maybe truncate? + span.set_attribute("odoo.groupby", str(groupby)) # maybe truncate? + if limit: + span.set_attribute("odoo.limit", limit) + span.set_attribute("odoo.offset", offset) + if orderby: + span.set_attribute("odoo.orderby", str(orderby)) + span.set_attribute("odoo.lazy", lazy) + + try: + res = _model_web_read_group( + self, + domain, + fields, + groupby, + limit=limit, + offset=offset, + orderby=orderby, + lazy=lazy, + ) + span.set_attribute("odoo.result_count", len(res)) + span.set_status(Status(StatusCode.OK)) + return res + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise + + +_model_web_read_group = WebModel.web_read_group +_patch_model(WebModel, "web_read_group", web_read_group) + + +def onchange(self, values, field_names, fields_spec): + if not bootstrap._OTEL_INITIALIZED: + return _model_onchange(self, values, field_names, fields_spec) + tracer = trace.get_tracer("odoo.otel.models") + with tracer.start_as_current_span("model.onchange", kind=SpanKind.INTERNAL) as span: + trace_common(span, self) + span.set_attribute("odoo.model", self._name) + span.set_attribute("odoo.values", str(values)) # maybe truncate? + span.set_attribute("odoo.field_names", str(field_names)) + span.set_attribute("odoo.fields_spec", str(fields_spec)) # maybe truncate? + + try: + res = _model_onchange(self, values, field_names, fields_spec) + span.set_status(Status(StatusCode.OK)) + return res + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise + + +_model_onchange = models.Model.onchange +_patch_model(models.Model, "onchange", onchange) + + +# other Odoo ORM methods + + +@api.model +@api.readonly +def search_count(self, domain, limit=None): + if not bootstrap._OTEL_INITIALIZED: + return _model_search_count(self, domain, limit=limit) + tracer = trace.get_tracer("odoo.otel.models") + with tracer.start_as_current_span( + "model.search_count", kind=SpanKind.INTERNAL + ) as span: + trace_common(span, self) + span.set_attribute("odoo.model", self._name) + span.set_attribute("odoo.domain", str(domain)) + if limit: + span.set_attribute("odoo.limit", limit) + + try: + res = _model_search_count(self, domain, limit=limit) + span.set_attribute("odoo.result_count", res) + span.set_status(Status(StatusCode.OK)) + return res + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise + + +_model_search_count = models.Model.search_count +_patch_model(models.Model, "search_count", search_count) + + +@api.model +@api.readonly +@api.returns("self") +def search(self, domain, offset=0, limit=None, order=None): + if not bootstrap._OTEL_INITIALIZED: + return _model_search(self, domain, offset=offset, limit=limit, order=order) + tracer = trace.get_tracer("odoo.otel.models") + with tracer.start_as_current_span("model.search", kind=SpanKind.INTERNAL) as span: + trace_common(span, self) + span.set_attribute("odoo.model", self._name) + span.set_attribute("odoo.domain", str(domain)) + span.set_attribute("odoo.offset", offset) + if limit: + span.set_attribute("odoo.limit", limit) + if order: + span.set_attribute("odoo.order", order) + + try: + res = _model_search(self, domain, offset=offset, limit=limit, order=order) + span.set_attribute("odoo.result_count", len(res)) + span.set_attribute("odoo.res_ids", str(res.ids)) # maybe truncate? + span.set_status(Status(StatusCode.OK)) + return res + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise + + +_model_search = models.Model.search +_patch_model(models.Model, "search", search) + + +@api.model +@api.readonly +@api.returns("self") +def search_fetch(self, domain, field_names, offset=0, limit=None, order=None): + if not bootstrap._OTEL_INITIALIZED: + return _model_search_fetch( + self, domain, field_names, offset=offset, limit=limit, order=order + ) + tracer = trace.get_tracer("odoo.otel.models") + with tracer.start_as_current_span( + "model.search_fetch", kind=SpanKind.INTERNAL + ) as span: + trace_common(span, self) + span.set_attribute("odoo.model", self._name) + span.set_attribute("odoo.domain", str(domain)) + span.set_attribute("odoo.field_names", str(field_names)) # maybe truncate? + span.set_attribute("odoo.offset", offset) + if limit: + span.set_attribute("odoo.limit", limit) + if order: + span.set_attribute("odoo.order", order) + + try: + res = _model_search_fetch( + self, domain, field_names, offset=offset, limit=limit, order=order + ) + span.set_attribute("odoo.result_count", len(res)) + span.set_status(Status(StatusCode.OK)) + return res + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise + + +_model_search_fetch = models.Model.search_fetch +_patch_model(models.Model, "search_fetch", search_fetch) + + +@api.model +def name_create(self, name): + if not bootstrap._OTEL_INITIALIZED: + return _model_name_create(self, name) + tracer = trace.get_tracer("odoo.otel.models") + with tracer.start_as_current_span( + "model.name_create", kind=SpanKind.INTERNAL + ) as span: + trace_common(span, self) + span.set_attribute("odoo.model", self._name) + span.set_attribute("odoo.name", name) + + try: + res = _model_name_create(self, name) + span.set_attribute("odoo.res_id", res[0]) + span.set_status(Status(StatusCode.OK)) + return res + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise + + +_model_name_create = models.Model.name_create +_patch_model(models.Model, "name_create", name_create) + + +@api.model +@api.readonly +def name_search(self, name="", args=None, operator="ilike", limit=100): + if not bootstrap._OTEL_INITIALIZED: + return _model_name_search( + self, name=name, args=args, operator=operator, limit=limit + ) + tracer = trace.get_tracer("odoo.otel.models") + with tracer.start_as_current_span( + "model.name_search", kind=SpanKind.INTERNAL + ) as span: + trace_common(span, self) + span.set_attribute("odoo.model", self._name) + span.set_attribute("odoo.name", name) + span.set_attribute("odoo.args", str(args)) + span.set_attribute("odoo.operator", operator) + span.set_attribute("odoo.limit", limit) + + try: + res = _model_name_search( + self, name=name, args=args, operator=operator, limit=limit + ) + span.set_attribute("odoo.result_count", len(res)) + span.set_attribute( + "odoo.res_ids", str([r[0] for r in res]) + ) # maybe truncate? + span.set_status(Status(StatusCode.OK)) + return res + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise + + +_model_name_search = models.Model.name_search +_patch_model(models.Model, "name_search", name_search) + + +@api.model +@api.readonly +def read_group( + self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True +): + if not bootstrap._OTEL_INITIALIZED: + return _model_read_group( + self, + domain, + fields, + groupby, + offset=offset, + limit=limit, + orderby=orderby, + lazy=lazy, + ) + tracer = trace.get_tracer("odoo.otel.models") + with tracer.start_as_current_span( + "model.read_group", kind=SpanKind.INTERNAL + ) as span: + trace_common(span, self) + span.set_attribute("odoo.model", self._name) + span.set_attribute("odoo.domain", str(domain)) + span.set_attribute("odoo.fields", str(fields)) # maybe truncate? + span.set_attribute("odoo.groupby", str(groupby)) # maybe truncate? + span.set_attribute("odoo.offset", offset) + if limit: + span.set_attribute("odoo.limit", limit) + if orderby: + span.set_attribute("odoo.orderby", str(orderby)) + span.set_attribute("odoo.lazy", lazy) + + try: + res = _model_read_group( + self, + domain, + fields, + groupby, + offset=offset, + limit=limit, + orderby=orderby, + lazy=lazy, + ) + span.set_attribute("odoo.result_count", len(res)) + span.set_status(Status(StatusCode.OK)) + return res + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise + + +_model_read_group = models.Model.read_group +_patch_model(models.Model, "read_group", read_group) + + +def fetch(self, field_names): + if not bootstrap._OTEL_INITIALIZED: + return _model_fetch(self, field_names) + tracer = trace.get_tracer("odoo.otel.models") + with tracer.start_as_current_span("model.fetch", kind=SpanKind.INTERNAL) as span: + trace_common(span, self) + span.set_attribute("odoo.model", self._name) + span.set_attribute("odoo.field_names", str(field_names)) # maybe truncate? + span.set_attribute("odoo.res_ids", str(self.ids)) # maybe truncate? + + try: + res = _model_fetch(self, field_names) + span.set_status(Status(StatusCode.OK)) + return res + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise + + +_model_fetch = models.Model.fetch +_patch_model(models.Model, "fetch", fetch) + + +@api.returns("self") +def copy(self, default=None): + if not bootstrap._OTEL_INITIALIZED: + return _model_copy(self, default=default) + tracer = trace.get_tracer("odoo.otel.models") + with tracer.start_as_current_span("model.copy", kind=SpanKind.INTERNAL) as span: + trace_common(span, self) + span.set_attribute("odoo.model", self._name) + span.set_attribute("odoo.res_ids", str(self.ids)) # maybe truncate? + span.set_attribute("odoo.default", str(default)) # maybe truncate? + + try: + res = _model_copy(self, default=default) + span.set_attribute("odoo.new_res_ids", str(res.ids)) # maybe truncate? + span.set_status(Status(StatusCode.OK)) + return res + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise + + +_model_copy = models.Model.copy +_patch_model(models.Model, "copy", copy) + + +@api.model +@api.readonly +def search_read( + self, domain=None, fields=None, offset=0, limit=None, order=None, **read_kwargs +): + if not bootstrap._OTEL_INITIALIZED: + return _model_search_read( + self, + domain=domain, + fields=fields, + offset=offset, + limit=limit, + order=order, + **read_kwargs, + ) + tracer = trace.get_tracer("odoo.otel.models") + with tracer.start_as_current_span( + "model.search_read", kind=SpanKind.INTERNAL + ) as span: + trace_common(span, self) + span.set_attribute("odoo.model", self._name) + span.set_attribute("odoo.domain", str(domain)) + span.set_attribute("odoo.fields", str(fields)) # maybe truncate? + span.set_attribute("odoo.offset", offset) + if limit: + span.set_attribute("odoo.limit", limit) + if order: + span.set_attribute("odoo.order", order) + span.set_attribute("odoo.read_kwargs", str(read_kwargs)) # maybe truncate? + + try: + res = _model_search_read( + self, + domain=domain, + fields=fields, + offset=offset, + limit=limit, + order=order, + **read_kwargs, + ) + span.set_attribute("odoo.result_count", len(res)) + span.set_status(Status(StatusCode.OK)) + return res + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR)) + raise + + +_model_search_read = models.Model.search_read +_patch_model(models.Model, "search_read", search_read) + + +def patch_models(): + for model, method_name, method in METHODS_TO_PATCH: + setattr(model, method_name, method) diff --git a/otel/post_load.py b/otel/post_load.py new file mode 100644 index 00000000000..3cbc990be02 --- /dev/null +++ b/otel/post_load.py @@ -0,0 +1,7 @@ +from .bootstrap import init_otel +from .models import patch_models + + +def post_load(): + init_otel() + patch_models() diff --git a/otel/pyproject.toml b/otel/pyproject.toml index 1c45ffcf658..4231d0cccb3 100644 --- a/otel/pyproject.toml +++ b/otel/pyproject.toml @@ -1,3 +1,3 @@ [build-system] requires = ["whool"] -build-backend = "whool.buildapi" \ No newline at end of file +build-backend = "whool.buildapi" diff --git a/otel/readme/CONFIGURE.md b/otel/readme/CONFIGURE.md new file mode 100644 index 00000000000..ee2dce036ce --- /dev/null +++ b/otel/readme/CONFIGURE.md @@ -0,0 +1,70 @@ +The module aims to accomodate different use-cases, and as such, there are quite a +few configuration options. Generally, only a few basic options are needed for most +deployments. + +# Minimum Config + +For most basic use-cases, the following config will be sufficient. + +*Note*: This assumes a collector is running locally, and accepts `http/protobuf` +requests. + +```ini +# odoo-server.conf +[options] +# ... + +[otel] +# enable the module +enable = True + +# these vars will be tacked onto *all* traces; you should set `service.name` at least +resource_attributes = service.name=odoo,odoo.version=18.0,deployment.environment=dev + +# your OTLP endpoint +exporter_otlp_endpoint = http://localhost:4318/v1/traces + +# you may also need to set this, if your collector wants gRPC +# exporter_otlp_protocol = grpc +``` + +Configuration options can be passed either by environment variables, or set in the +Odoo conf file. Environment variables take precedence. Option names are consistent +between these two options. To convert from env-var toconf, simply strip the leading +`OTEL_`, and make lowercase. For example: + - `OTEL_ENABLE` -> `enable` + - `OTEL_EXPORTER_OTLP_ENDPOINT` -> `exporter_otlp_endpoint` + - etc + +# Config Reference + +## Core Options + + * `OTEL_ENABLE` will enable (or disable) the module. Possible values: `true` or + `false` + * `OTEL_RESOURCE_ATTRIBUTES` should be a set of `key1=value,key2=value` pairs. You + should set `service.name` at least, and consider setting + `deployment.environment` + * `OTEL_RESOURCE_ATTRIBUTES_SERVICE_NAME` will override the `service.name` + attribute in the Resource Attributes + * `OTEL_RESOURCE_ATTRIBUTES_SERVICE_VERSION` as above, but for the `service.version` + attribute; useful if you want to set the version dynamically (e.g., to a + docker build hash, or git revision) + * `OTEL_RESOURCE_ATTRIBUTES_DEPLOYMENT_ENVIRONMENT` as above, but for the + `deployment.environment` attribute (again, useful to be configureable + dynamically) + +## Collector Options + + * `OTEL_EXPORTER_OTLP_PROTOCOL` is the protocol the OTel SDK will use to + communicate with your collector. Possible values: `grpc` or `http` (which + is an alias for `http/protobuf`) + * `OTEL_EXPORTER_OTLP_ENDPOINT` is the collector endpoint. Examples are: + * `http://localhost:4317` (gRPC) + * `http://localhost:4318` (http/protobuf) + * `OTEL_EXPORTER_OTLP_HEADERS` accepts a set of `header=value,header2=value` + pairs, which will be sent along with requests to the collector. This is + useful for passing auth headers. Examples are: + * `exporter_otlp_headers = Authorization=Bearer 12345` + +## \ No newline at end of file diff --git a/otel/readme/CONTRIBUTORS.md b/otel/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..49dcf2eddec --- /dev/null +++ b/otel/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ + - Ryan Cole \<\> diff --git a/otel/readme/DESCRIPTION.md b/otel/readme/DESCRIPTION.md new file mode 100644 index 00000000000..d618dd226cc --- /dev/null +++ b/otel/readme/DESCRIPTION.md @@ -0,0 +1,3 @@ +This module instruments Odoo with OpenTelemetry support, including `traceparent` +support for distributed tracing and correlation. It also includes optional support for +instrumenting PostgreSQL (via psycopg2). diff --git a/otel/readme/INSTALL.md b/otel/readme/INSTALL.md new file mode 100644 index 00000000000..6136dcd0d5b --- /dev/null +++ b/otel/readme/INSTALL.md @@ -0,0 +1,16 @@ +This module should be added to your addons directory just like any other module. +You should then add the module to your `server_wide_modules`, e.g.: + +```ini +# odoo-server.conf + +[options] +# ... +server_wide_modules = base,web,otel +``` + +You will also need to install the Python dependencies via pip: + +```shell +pip3 install -r /path/to/otel/requirements.txt +``` diff --git a/otel/requirements.txt b/otel/requirements.txt index 9b16105bd38..fb9fcfa6dee 100644 --- a/otel/requirements.txt +++ b/otel/requirements.txt @@ -2,3 +2,4 @@ opentelemetry-api>=1.39,<2 opentelemetry-sdk>=1.39,<2 opentelemetry-exporter-otlp-proto-grpc>=1.39,<2 opentelemetry-exporter-otlp-proto-http>=1.39,<2 +opentelemetry-instrumentation-psycopg2>=0.60b1,<1 diff --git a/otel/static/description/index.html b/otel/static/description/index.html new file mode 100644 index 00000000000..08c289c5fcd --- /dev/null +++ b/otel/static/description/index.html @@ -0,0 +1,534 @@ + + + + + +OpenTelemetry + + + +
+

OpenTelemetry

+ + +

Beta License: LGPL-3 OCA/server-tools Translate me on Weblate Try me on Runboat

+

This module instruments Odoo with OpenTelemetry support, including +traceparent support for distributed tracing and correlation. It also +includes optional support for instrumenting PostgreSQL (via psycopg2).

+

Table of contents

+ +
+

Installation

+

This module should be added to your addons directory just like any other +module. You should then add the module to your server_wide_modules, +e.g.:

+
+# odoo-server.conf
+
+[options]
+# ...
+server_wide_modules = base,web,otel
+
+

You will also need to install the Python dependencies via pip:

+
+pip3 install -r /path/to/otel/requirements.txt
+
+
+
+

Configuration

+

The module aims to accomodate different use-cases, and as such, there +are quite a few configuration options. Generally, only a few basic +options are needed for most deployments.

+
+
+

Minimum Config

+

For most basic use-cases, the following config will be sufficient.

+

Note: This assumes a collector is running locally, and accepts +http/protobuf requests.

+
+# odoo-server.conf
+[options]
+# ...
+
+[otel]
+# enable the module
+enable = True
+
+# these vars will be tacked onto *all* traces; you should set `service.name` at least
+resource_attributes = service.name=odoo,odoo.version=18.0,deployment.environment=dev
+
+# your OTLP endpoint
+exporter_otlp_endpoint = http://localhost:4318/v1/traces
+
+# you may also need to set this, if your collector wants gRPC
+# exporter_otlp_protocol = grpc
+
+

Configuration options can be passed either by environment variables, or +set in the Odoo conf file. Environment variables take precedence. Option +names are consistent between these two options. To convert from env-var +toconf, simply strip the leading OTEL_, and make lowercase. For +example:

+
    +
  • OTEL_ENABLE -> enable
  • +
  • OTEL_EXPORTER_OTLP_ENDPOINT -> exporter_otlp_endpoint
  • +
  • etc
  • +
+
+
+

Config Reference

+
+

Core Options

+
    +
  • OTEL_ENABLE will enable (or disable) the module. Possible values: +true or false
  • +
  • OTEL_RESOURCE_ATTRIBUTES should be a set of +key1=value,key2=value pairs. You should set service.name at +least, and consider setting deployment.environment
  • +
  • OTEL_RESOURCE_ATTRIBUTES_SERVICE_NAME will override the +service.name attribute in the Resource Attributes
  • +
  • OTEL_RESOURCE_ATTRIBUTES_SERVICE_VERSION as above, but for the +service.version attribute; useful if you want to set the version +dynamically (e.g., to a docker build hash, or git revision)
  • +
  • OTEL_RESOURCE_ATTRIBUTES_DEPLOYMENT_ENVIRONMENT as above, but for +the deployment.environment attribute (again, useful to be +configureable dynamically)
  • +
+
+
+

Collector Options

+
    +
  • OTEL_EXPORTER_OTLP_PROTOCOL is the protocol the OTel SDK will use +to communicate with your collector. Possible values: grpc or +http (which is an alias for http/protobuf)
  • +
  • OTEL_EXPORTER_OTLP_ENDPOINT is the collector endpoint. Examples +are:
      +
    • http://localhost:4317 (gRPC)
    • +
    • http://localhost:4318 (http/protobuf)
    • +
    +
  • +
  • OTEL_EXPORTER_OTLP_HEADERS accepts a set of +header=value,header2=value pairs, which will be sent along with +requests to the collector. This is useful for passing auth headers. +Examples are:
      +
    • exporter_otlp_headers = Authorization=Bearer 12345
    • +
    +
  • +
+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Ryan Cole
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

ryanc-me

+

This module is part of the OCA/server-tools project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/otel/utils.py b/otel/utils.py new file mode 100644 index 00000000000..619fe9daa29 --- /dev/null +++ b/otel/utils.py @@ -0,0 +1,27 @@ +import logging + +from opentelemetry.propagate import extract + +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +def _is_trusted_inbound_request(): + try: + return bool(getattr(request, "uid", None)) + except Exception: + return False + + +def extract_context(): + if not _is_trusted_inbound_request(): + return None + + try: + headers = request.httprequest.headers + ctx = extract(headers) + return ctx + except Exception: + _logger.exception("Failed to extract inbound trace context") + return None diff --git a/otel_web/DESIGN.md b/otel_web/DESIGN.md new file mode 100644 index 00000000000..6b11c4bcab6 --- /dev/null +++ b/otel_web/DESIGN.md @@ -0,0 +1,162 @@ +otel_web MVP Spec (quick) Goal + +Enable browser (Owl) frontend tracing with maximum interoperability by exporting OTLP +over HTTP via a same-origin reverse proxy path to a local OpenTelemetry Collector, while +optionally restricting export to authenticated Odoo users. + +Architecture Standard deployment (recommended) + +Browser → NGINX (reverse proxy) → Collector (OTLP HTTP receiver) → backends + +Browser exports OTLP/HTTP to a same-origin endpoint: + +https:///otlp/v1/traces + +NGINX proxies that path to a local Collector (e.g. http://127.0.0.1:4318/v1/traces) + +Collector exports to the chosen backend(s) + +Important: NGINX’s ngx_otel_module is for NGINX generating/exporting its own spans to +the Collector; it is not an OTLP receiver for browser spans. Collector is the OTLP +receiver. + +Data & Protocol Export protocol (frontend) + +Use OTLP/HTTP with the standard OTLP paths (/v1/traces) + +Prefer same-origin endpoint to avoid CORS complexity and keep cookies flowing naturally. + +Propagation (correlation) + +Frontend should also attach W3C Trace Context headers (traceparent, optionally +tracestate) to Odoo RPC requests so backend spans become children of frontend spans. + +Security & Access Control Options Option 1: Authenticated-only export (recommended +default) + +Restrict OTLP ingestion to users with a valid Odoo session. + +Mechanism: NGINX auth_request + +NGINX uses auth_request to call an internal auth-check endpoint on Odoo. + +If Odoo returns 2xx, NGINX proxies the OTLP payload to the Collector. + +If Odoo returns 401/403, request is rejected. + +auth_request is designed for this subrequest authorization flow. Odoo uses session +cookies (commonly session_id) for authenticated web requests. + +Odoo endpoint (in otel_web): + +GET /otel/auth_check + +Returns: + +204 No Content (or 200) if session is authenticated (request.session.uid exists) + +401 Unauthorized otherwise + +Optional mode: admin-only (check membership in a configured group; return 403 for +non-admin). + +Option 2: Allow all users (public website tracing) + +No auth_request + +Rely on strict NGINX controls (rate limiting, payload size caps, Origin checks) + +Recommend conservative client-side sampling. + +CSRF / browser abuse mitigation + +For OTLP export, prefer standard web mitigations rather than Odoo CSRF tokens (to keep +compatibility with OTel exporters): + +Same-origin endpoint (avoid permissive CORS) + +Origin/Referer checks at NGINX for /otlp/\* + +SameSite cookies (deployment-dependent, but helpful) + +Rate limiting + payload caps (mandatory) + +(Do not require Odoo CSRF tokens for OTLP export unless you want a custom exporter +flow.) + +NGINX requirements (documented, not implemented in Odoo) + +Provide a reference config snippet that: + +Exposes /otlp/v1/traces publicly + +Proxies to local collector OTLP HTTP receiver (127.0.0.1:4318) + +Enforces: + +limit_req (per IP / per session if feasible) + +client_max_body_size + +optional Origin/Referer allowlist + +optional auth_request → Odoo /otel/auth_check + +Collector requirements (documented) + +Enable OTLP receiver HTTP (and gRPC if needed for NGINX spans) + +Recommend local deployment (same host as Odoo/NGINX) for minimal latency and no extra +third-party hop. + +otel_web module deliverables + +1. Frontend changes + +Patch Owl RPC layer to: + +propagate traceparent (and optionally tracestate) on Odoo RPC calls for trace +correlation. + +Provide optional JS initializer/config for OTel Web SDK exporter: + +OTLP/HTTP endpoint: /otlp/v1/traces + +sampling controls (recommended defaults conservative) + +2. Backend endpoint (only for auth gating) + +Implement /otel/auth_check controller endpoint: + +fast, minimal logic + +returns only status codes (2xx/401/403) + +no payload, no side effects + +This endpoint is used exclusively by NGINX auth_request. + +3. Documentation + +Reference NGINX + Collector configs for: + +authenticated-only RUM + +public RUM + +Clear warnings on: + +rate limiting requirements + +payload caps + +baggage/PII considerations (don’t attach arbitrary baggage to spans by default) + +Non-goals for MVP + +Odoo acting as an OTLP receiver/forwarder (avoid loading Odoo workers) + +Custom “submit spans in RPC payload” approaches (breaks standard OTel export + loses +proper timing) + +Mandatory baggage support (keep disabled by default) diff --git a/otel_web/__init__.py b/otel_web/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/otel_web/__manifest__.py b/otel_web/__manifest__.py new file mode 100644 index 00000000000..d70bfd6a6f2 --- /dev/null +++ b/otel_web/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2025 Ryan Cole (https://www.ryanc.me) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +{ + "name": "OpenTelemetry (Web Frontend)", + "summary": "OpenTelemetry integration for Odoo's web frontend", + "author": "Ryan Cole, Odoo Community Association (OCA)", + "maintainers": ["ryanc-me"], + "category": "Technical", + "website": "https://github.com/OCA/server-tools", + "version": "18.0.1.0.0", + "license": "LGPL-3", + "installable": True, + "depends": ["base"], +} diff --git a/otel_web/pyproject.toml b/otel_web/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/otel_web/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi"