From d1f35b2be4414ee69373649661335a3d8cce6fd2 Mon Sep 17 00:00:00 2001
From: "Moises Lopez - https://www.vauxoo.com/"
Date: Sat, 12 Sep 2026 16:17:32 -0600
Subject: [PATCH] [IMP] sentry: configure through environment variables
Every option can now come from ODOO_SENTRY_*, named after the option it
carries: sentry_dsn is ODOO_SENTRY_DSN. That is the shape queue_job has
been using for ODOO_QUEUE_JOB_* for years.
Both sources stay supported and are merged per option, the environment
winning where the two disagree, so an existing [sentry] section keeps
working untouched.
A container has two reasons to prefer the environment. Odoo reads only
[options] from its configuration file and logs "unknown option ... in
the config file" for anything else it finds there, which is why these
options were moved out to a [sentry] section to begin with. And
odoo-bin -s rewrites that file out of Odoo's own options, dropping every
other section with it.
The ODOO_ prefix keeps these apart from the SENTRY_* variables
sentry-sdk reads on its own. SENTRY_DSN addresses whichever process the
library runs in, and it cannot serve here anyway: the dsn is always
passed explicitly, and sentry-sdk only falls back to the environment for
an option it receives as None.
Options that answer yes or no now go through to_bool. A configuration
file and an environment variable can only deliver a string, and
bool("False") is True, so sentry_enabled = False used to turn sentry on.
---
sentry/README.rst | 48 +++++++++++++++
sentry/const.py | 52 +++++++++++++++++
sentry/hooks.py | 15 ++++-
sentry/readme/CONFIGURE.md | 41 +++++++++++++
sentry/static/description/index.html | 81 ++++++++++++++++++++------
sentry/tests/test_client.py | 87 +++++++++++++++++++++++++++-
6 files changed, 302 insertions(+), 22 deletions(-)
diff --git a/sentry/README.rst b/sentry/README.rst
index e77d84932bf..c751eb570c9 100644
--- a/sentry/README.rst
+++ b/sentry/README.rst
@@ -72,6 +72,36 @@ your Odoo config file. Currently supported additional client arguments
are:
``with_locals, max_breadcrumbs, release, environment, server_name, shutdown_timeout, in_app_include, in_app_exclude, default_integrations, dist, sample_rate, send_default_pii, http_proxy, https_proxy, request_bodies, debug, attach_stacktrace, ca_certs, propagate_traces, traces_sample_rate, auto_enabling_integrations``.
+Environment variables
+---------------------
+
+Every option above can also be set through an environment variable,
+named after the option it carries with an ``ODOO_`` prefix:
+``sentry_dsn`` becomes ``ODOO_SENTRY_DSN``,
+``sentry_traces_sample_rate`` becomes
+``ODOO_SENTRY_TRACES_SAMPLE_RATE``. This is the same shape *queue_job*
+uses for ``ODOO_QUEUE_JOB_*``.
+
+Both sources stay supported and are merged per option, the environment
+winning where the two disagree. An existing ``[sentry]`` section keeps
+working as it is, and a deployment that would rather not ship a
+configuration file at all can set everything through the environment
+instead.
+
+The ``ODOO_`` prefix is what keeps these apart from the ``SENTRY_*``
+variables *sentry-sdk* reads on its own. ``SENTRY_DSN`` addresses
+whichever process the library runs in, so it is left alone rather than
+reused here.
+
+Two reasons a container may prefer the environment:
+
+- Odoo reads only ``[options]`` from its configuration file, and logs
+ ``unknown option ... in the config file`` for anything there it does
+ not know. The ``[sentry]`` section is quiet, but it exists only
+ because these options had to leave ``[options]`` for that reason.
+- ``odoo-bin -s`` rewrites the configuration file out of its own
+ options, which drops every other section, ``[sentry]`` included.
+
Example Odoo configuration
--------------------------
@@ -100,6 +130,24 @@ options:
sentry_odoo_dir = /home/odoo/odoo/
sentry_startup_message = true
+Example environment
+-------------------
+
+The same setup, with ``server_wide_modules`` left in the configuration
+file because that one is Odoo's own option:
+
+::
+
+ ODOO_SENTRY_DSN=https://:@sentry.example.com/
+ ODOO_SENTRY_ENABLED=true
+ ODOO_SENTRY_LOGGING_LEVEL=warn
+ ODOO_SENTRY_EXCLUDE_LOGGERS=werkzeug
+ ODOO_SENTRY_INCLUDE_CONTEXT=true
+ ODOO_SENTRY_ENVIRONMENT=production
+ ODOO_SENTRY_RELEASE=1.3.2
+ ODOO_SENTRY_ODOO_DIR=/home/odoo/odoo/
+ ODOO_SENTRY_STARTUP_MESSAGE=true
+
Usage
=====
diff --git a/sentry/const.py b/sentry/const.py
index f0205a6f0bc..18be6df9009 100644
--- a/sentry/const.py
+++ b/sentry/const.py
@@ -2,6 +2,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import collections
import logging
+import os
from sentry_sdk import HttpTransport
from sentry_sdk.consts import DEFAULT_OPTIONS
@@ -29,6 +30,26 @@ def to_float_if_defined(value):
return float(value)
+TRUTHY_VALUES = ("1", "on", "true", "yes")
+
+
+def to_bool(value, default=False):
+ """Read a boolean out of a value that a configuration file or an environment
+ variable can only deliver as a string.
+
+ ``bool("False")`` is ``True``, so a plain truth test on a string turns an option
+ the user switched off back on.
+ """
+ if value is None:
+ return default
+ if isinstance(value, str):
+ value = value.strip().lower()
+ # An option present but left empty says nothing, so it is not an answer of
+ # "off": keep the default the caller asked for.
+ return default if not value else value in TRUTHY_VALUES
+ return bool(value)
+
+
SentryOption = collections.namedtuple("SentryOption", ["key", "default", "converter"])
# Mapping of Odoo logging level -> Python stdlib logging library log level.
@@ -132,3 +153,34 @@ def get_sentry_options():
)
return res
+
+
+# ENV_OPTION_PREFIX is what the options are already called in the configuration
+# file, so ODOO_SENTRY_DSN and sentry_dsn name the same option and neither source
+# needs a translation table.
+ENV_PREFIX = "ODOO_"
+ENV_OPTION_PREFIX = "SENTRY_"
+
+
+def get_options_from_env(environ=None):
+ """Return the options set through environment variables.
+
+ Keys come back in the shape the configuration file uses, so the caller merges
+ both sources without caring where each value came from: ``ODOO_SENTRY_DSN``
+ becomes ``sentry_dsn``. Every option is covered, including the ones this module
+ does not name itself, because the prefix is what selects them rather than a
+ list that would have to be kept up to date.
+
+ The ``ODOO_`` prefix follows ``queue_job``, which reads ``ODOO_QUEUE_JOB_*``
+ this way, and keeps these apart from the ``SENTRY_*`` variables ``sentry-sdk``
+ reads on its own. ``SENTRY_DSN`` addresses whichever process the library runs
+ in, so reusing it here would point Odoo at a DSN meant for something else.
+ """
+ if environ is None:
+ environ = os.environ
+ prefix = f"{ENV_PREFIX}{ENV_OPTION_PREFIX}"
+ return {
+ name[len(ENV_PREFIX) :].lower(): value
+ for name, value in environ.items()
+ if name.startswith(prefix)
+ }
diff --git a/sentry/hooks.py b/sentry/hooks.py
index 716592c6a3e..fe5adb9bfb8 100644
--- a/sentry/hooks.py
+++ b/sentry/hooks.py
@@ -101,7 +101,13 @@ def initialize_sentry(config):
:param config: Sentry configuration
:param client: class used to instantiate the sentry_sdk client.
"""
- enabled = config.get("sentry_enabled", False)
+ # An environment variable wins over the file, the precedence queue_job's
+ # jobrunner has been applying to ODOO_QUEUE_JOB_* for years. Both sources stay
+ # supported, so an existing [sentry] section keeps working untouched, and a
+ # deployment that would rather not carry a configuration file at all can set
+ # every option through the environment instead.
+ config = {**config, **const.get_options_from_env()}
+ enabled = const.to_bool(config.get("sentry_enabled", False))
if not (HAS_SENTRY_SDK and enabled):
return
_logger.info("Initializing sentry...")
@@ -149,7 +155,10 @@ def initialize_sentry(config):
client = sentry_sdk.init(**options)
- sentry_sdk.set_tag("include_context", config.get("sentry_include_context", True))
+ sentry_sdk.set_tag(
+ "include_context",
+ const.to_bool(config.get("sentry_include_context", True), default=True),
+ )
if exclude_loggers:
for item in exclude_loggers:
@@ -179,7 +188,7 @@ def sentry_application_call(self, environ, start_response):
odoo.http.Application.__call__ = sentry_application_call
- if config.get("sentry_startup_message", True) not in (False, "False", "false"):
+ if const.to_bool(config.get("sentry_startup_message", True), default=True):
with sentry_sdk.new_scope() as scope:
scope.set_extra("debug", False)
sentry_sdk.capture_message("Starting Odoo Server", "info")
diff --git a/sentry/readme/CONFIGURE.md b/sentry/readme/CONFIGURE.md
index f5ef4cfb605..5538b1ec31c 100644
--- a/sentry/readme/CONFIGURE.md
+++ b/sentry/readme/CONFIGURE.md
@@ -9,6 +9,32 @@ be configured by prepending the argument name with *sentry\_* in your
Odoo config file. Currently supported additional client arguments are:
`with_locals, max_breadcrumbs, release, environment, server_name, shutdown_timeout, in_app_include, in_app_exclude, default_integrations, dist, sample_rate, send_default_pii, http_proxy, https_proxy, request_bodies, debug, attach_stacktrace, ca_certs, propagate_traces, traces_sample_rate, auto_enabling_integrations`.
+## Environment variables
+
+Every option above can also be set through an environment variable, named after
+the option it carries with an `ODOO_` prefix: `sentry_dsn` becomes
+`ODOO_SENTRY_DSN`, `sentry_traces_sample_rate` becomes
+`ODOO_SENTRY_TRACES_SAMPLE_RATE`. This is the same shape *queue_job* uses for
+`ODOO_QUEUE_JOB_*`.
+
+Both sources stay supported and are merged per option, the environment winning
+where the two disagree. An existing `[sentry]` section keeps working as it is,
+and a deployment that would rather not ship a configuration file at all can set
+everything through the environment instead.
+
+The `ODOO_` prefix is what keeps these apart from the `SENTRY_*` variables
+*sentry-sdk* reads on its own. `SENTRY_DSN` addresses whichever process the
+library runs in, so it is left alone rather than reused here.
+
+Two reasons a container may prefer the environment:
+
+- Odoo reads only `[options]` from its configuration file, and logs
+ `unknown option ... in the config file` for anything there it does not know.
+ The `[sentry]` section is quiet, but it exists only because these options had
+ to leave `[options]` for that reason.
+- `odoo-bin -s` rewrites the configuration file out of its own options, which
+ drops every other section, `[sentry]` included.
+
## Example Odoo configuration
Below is an example of Odoo configuration file with *Odoo Sentry*
@@ -33,3 +59,18 @@ options:
sentry_release = 1.3.2
sentry_odoo_dir = /home/odoo/odoo/
sentry_startup_message = true
+
+## Example environment
+
+The same setup, with `server_wide_modules` left in the configuration file
+because that one is Odoo's own option:
+
+ ODOO_SENTRY_DSN=https://:@sentry.example.com/
+ ODOO_SENTRY_ENABLED=true
+ ODOO_SENTRY_LOGGING_LEVEL=warn
+ ODOO_SENTRY_EXCLUDE_LOGGERS=werkzeug
+ ODOO_SENTRY_INCLUDE_CONTEXT=true
+ ODOO_SENTRY_ENVIRONMENT=production
+ ODOO_SENTRY_RELEASE=1.3.2
+ ODOO_SENTRY_ODOO_DIR=/home/odoo/odoo/
+ ODOO_SENTRY_STARTUP_MESSAGE=true
diff --git a/sentry/static/description/index.html b/sentry/static/description/index.html
index 5e3c5011d95..65ad2b9e336 100644
--- a/sentry/static/description/index.html
+++ b/sentry/static/description/index.html
@@ -382,17 +382,19 @@ Sentry
- Installation
- Configuration
-- Usage
-- Known issues / Roadmap
-- Bug Tracker
-- Credits
@@ -422,8 +424,35 @@
your Odoo config file. Currently supported additional client arguments
are:
with_locals, max_breadcrumbs, release, environment, server_name, shutdown_timeout, in_app_include, in_app_exclude, default_integrations, dist, sample_rate, send_default_pii, http_proxy, https_proxy, request_bodies, debug, attach_stacktrace, ca_certs, propagate_traces, traces_sample_rate, auto_enabling_integrations.
+
+
+
Every option above can also be set through an environment variable,
+named after the option it carries with an ODOO_ prefix:
+sentry_dsn becomes ODOO_SENTRY_DSN,
+sentry_traces_sample_rate becomes
+ODOO_SENTRY_TRACES_SAMPLE_RATE. This is the same shape queue_job
+uses for ODOO_QUEUE_JOB_*.
+
Both sources stay supported and are merged per option, the environment
+winning where the two disagree. An existing [sentry] section keeps
+working as it is, and a deployment that would rather not ship a
+configuration file at all can set everything through the environment
+instead.
+
The ODOO_ prefix is what keeps these apart from the SENTRY_*
+variables sentry-sdk reads on its own. SENTRY_DSN addresses
+whichever process the library runs in, so it is left alone rather than
+reused here.
+
Two reasons a container may prefer the environment:
+
+- Odoo reads only [options] from its configuration file, and logs
+unknown option ... in the config file for anything there it does
+not know. The [sentry] section is quiet, but it exists only
+because these options had to leave [options] for that reason.
+- odoo-bin -s rewrites the configuration file out of its own
+options, which drops every other section, [sentry] included.
+
+
-
+
Below is an example of Odoo configuration file with Odoo Sentry
options:
@@ -448,16 +477,32 @@
+
+
+
The same setup, with server_wide_modules left in the configuration
+file because that one is Odoo’s own option:
+
+ODOO_SENTRY_DSN=https://<public_key>:<secret_key>@sentry.example.com/<project id>
+ODOO_SENTRY_ENABLED=true
+ODOO_SENTRY_LOGGING_LEVEL=warn
+ODOO_SENTRY_EXCLUDE_LOGGERS=werkzeug
+ODOO_SENTRY_INCLUDE_CONTEXT=true
+ODOO_SENTRY_ENVIRONMENT=production
+ODOO_SENTRY_RELEASE=1.3.2
+ODOO_SENTRY_ODOO_DIR=/home/odoo/odoo/
+ODOO_SENTRY_STARTUP_MESSAGE=true
+
+
-
+
Once configured and installed, the module will report any logging event
at and above the configured Sentry logging level, no additional actions
are necessary.

-
+
- No database separation – This module functions by intercepting
all Odoo logging records in a running Odoo process. This means that
@@ -473,7 +518,7 @@
-
+
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
@@ -481,9 +526,9 @@
Do not contact contributors directly about support or help with technical issues.
-
+
-
+
- Mohammed Barsi
- Versada
@@ -492,7 +537,7 @@
-
+
This module is maintained by the OCA.
diff --git a/sentry/tests/test_client.py b/sentry/tests/test_client.py
index 228fb12e590..c0b77d82f2e 100644
--- a/sentry/tests/test_client.py
+++ b/sentry/tests/test_client.py
@@ -3,6 +3,7 @@
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import inspect
import logging
+import os
import sys
from unittest.mock import patch
@@ -14,8 +15,9 @@
from odoo import exceptions
from odoo.tests import TransactionCase
+from .. import const
from .. import hooks as sentry_hooks
-from ..const import to_int_if_defined
+from ..const import get_options_from_env, to_bool, to_int_if_defined
from ..hooks import before_send, initialize_sentry
GIT_SHA = "d670460b4b4aece5915caf5c68d12f560a9fe3e4"
@@ -80,6 +82,7 @@ class TestClientSetup(TransactionCase):
def setUp(self):
super().setUp()
self.dsn = "http://public:secret@example.com/1"
+ self.clear_env()
self.patch_config(
{
"sentry_enabled": True,
@@ -111,6 +114,27 @@ def patch_config(self, options: dict):
_config_patcher.start()
self.addCleanup(_config_patcher.stop)
+ def patch_env(self, variables: dict):
+ """
+ Set `variables` in the environment, ensuring that they are unset again
+ when the test completes.
+ """
+ _env_patcher = patch.dict(os.environ, values=variables)
+ _env_patcher.start()
+ self.addCleanup(_env_patcher.stop)
+
+ def clear_env(self):
+ """
+ Drop the ODOO_SENTRY_* variables the machine running the tests happens to
+ carry, so a test reads the configuration it sets up itself rather than the
+ environment of whoever runs it.
+ """
+ prefix = f"{const.ENV_PREFIX}{const.ENV_OPTION_PREFIX}"
+ carried = {k: v for k, v in os.environ.items() if k.startswith(prefix)}
+ for key in carried:
+ del os.environ[key]
+ self.addCleanup(os.environ.update, carried)
+
def log(self, level, msg, exc_info=None):
self.logger.log(level, msg, exc_info=exc_info)
@@ -243,6 +267,67 @@ def test_invalid_logging_level(self):
def test_undefined_to_int(self):
self.assertIsNone(to_int_if_defined(""))
+ def test_options_from_env_are_selected_by_prefix(self):
+ """Only ODOO_SENTRY_* is ours, and it is renamed to the key the file uses."""
+ environ = {
+ "ODOO_SENTRY_DSN": self.dsn,
+ "ODOO_SENTRY_TRACES_SAMPLE_RATE": "0.5",
+ # sentry-sdk reads this one on its own, for whichever process it runs in
+ "SENTRY_DSN": "http://public:secret@example.com/2",
+ "ODOO_QUEUE_JOB_CHANNELS": "root:1",
+ }
+ self.assertEqual(
+ get_options_from_env(environ),
+ {"sentry_dsn": self.dsn, "sentry_traces_sample_rate": "0.5"},
+ )
+
+ def test_to_bool_reads_the_strings_a_config_source_delivers(self):
+ for value in ("true", "True", "1", "on", "YES", True):
+ self.assertTrue(to_bool(value), f"{value!r} should read as enabled")
+ for value in ("false", "False", "0", "off", "no", False):
+ self.assertFalse(to_bool(value), f"{value!r} should read as disabled")
+ # Missing or left empty says nothing, so the caller's default answers.
+ self.assertTrue(to_bool(None, default=True))
+ self.assertTrue(to_bool(" ", default=True))
+ self.assertFalse(to_bool(None))
+
+ def test_configured_entirely_through_the_environment(self):
+ """No configuration file at all: every option comes from the environment."""
+ self.patch_env(
+ {
+ "ODOO_SENTRY_ENABLED": "true",
+ "ODOO_SENTRY_DSN": self.dsn,
+ }
+ )
+ client = initialize_sentry({})._client
+ self.assertEqual(client.dsn, self.dsn)
+
+ def test_environment_wins_over_the_configuration_file(self):
+ env_dsn = "http://public:secret@example.com/2"
+ self.patch_env({"ODOO_SENTRY_DSN": env_dsn})
+ client = initialize_sentry(sentry_hooks.sentry_config)._client
+ self.assertEqual(client.dsn, env_dsn)
+
+ def test_both_sources_are_merged_per_option(self):
+ """An option set in only one of the two sources still reaches the client."""
+ self.patch_env({"ODOO_SENTRY_ENVIRONMENT": "from-env"})
+ client = initialize_sentry(sentry_hooks.sentry_config)._client
+ self.assertEqual(client.dsn, self.dsn, "the file should still be read")
+ self.assertEqual(client.options["environment"], "from-env")
+
+ def test_sentry_sdk_own_dsn_variable_is_not_read_as_ours(self):
+ self.patch_env({"SENTRY_DSN": "http://public:secret@example.com/2"})
+ client = initialize_sentry(sentry_hooks.sentry_config)._client
+ self.assertEqual(client.dsn, self.dsn)
+
+ def test_enabled_reads_the_string_false_as_off(self):
+ self.patch_config({"sentry_enabled": "False"})
+ self.assertIsNone(initialize_sentry(sentry_hooks.sentry_config))
+
+ def test_enabled_from_the_environment_reads_the_string_false_as_off(self):
+ self.patch_env({"ODOO_SENTRY_ENABLED": "False"})
+ self.assertIsNone(initialize_sentry(sentry_hooks.sentry_config))
+
@patch("odoo.addons.sentry.hooks.get_odoo_commit", return_value=GIT_SHA)
def test_config_odoo_dir(self, get_odoo_commit):
self.patch_config({"sentry_odoo_dir": "/opt/odoo/core"})