diff --git a/sentry_client/README.rst b/sentry_client/README.rst
new file mode 100644
index 00000000000..9bd382a33b1
--- /dev/null
+++ b/sentry_client/README.rst
@@ -0,0 +1,430 @@
+====================
+Sentry — Browser SDK
+====================
+
+..
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ !! This file is generated by oca-gen-addon-readme !!
+ !! changes will be overwritten. !!
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ !! source digest: sha256:2806bcb76ed0eabb88da4ccee41b12bf8b2e013efd155c3bf592a56d77828c63
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+
+.. |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-AGPL--3-blue.png
+ :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
+ :alt: License: AGPL-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/sentry_client
+ :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-sentry_client
+ :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|
+
+Capture uncaught JS errors and unhandled promise rejections in the Odoo
+web client to Sentry, with optional Performance Monitoring
+(BrowserTracing), Session Replay, Browser CPU Profiling, and Console-log
+capture tiers behind explicit opt-in toggles.
+
+The Sentry browser SDK ships **vendored inside the module** — no
+external CDN call, air-gapped friendly out of the box.
+
+**Standalone:** works on its own. Reads DSN / release / environment from
+the ``[sentry]`` section of ``odoo.conf``. Captures browser-side errors
+only.
+
+**Better together with ``sentry``:** install alongside the server-side
+```sentry`` <../sentry>`__ module to cluster client and server errors
+for the same user / release / environment into one Sentry issue. Both
+modules share the same ``[sentry]`` config section by convention — fill
+in the section once and client + server events land in the same Sentry
+project.
+
+Each tier above Tier 0 is **off by default** and surfaces an in-form
+warning about its perf cost when enabled. Sample rates are sliders so
+admins can dial behaviour without a server restart. Individual users can
+opt out of session replay via their own preferences page.
+
+**Table of contents**
+
+.. contents::
+ :local:
+
+Configuration
+=============
+
+1. Pick where the DSN comes from
+--------------------------------
+
+There are two ways to point the browser SDK at a Sentry project. Use
+**one**:
+
+**(a) Recommended — separate browser DSN via the Settings UI.** Sentry's
+own guidance is one project per platform: a Python project for backend
+errors and a JavaScript-Browser project for client-side errors. Set the
+dedicated browser DSN under **Settings → General Settings → Sentry
+Browser Monitoring → Connection**:
+
++-----------------+------------------------+------------------------+
+| Field | Example | Notes |
++=================+========================+========================+
+| **Browser DSN** | ``https://
@sentry.exam | JavaScript project. |
+| | ple.com/`` | Safe to embed in |
+| | | client code per |
+| | | Sentry's docs. |
++-----------------+------------------------+------------------------+
+| **Environment** | ``production-web`` | Tags every browser |
+| | | event. May differ from |
+| | | the backend env tag. |
++-----------------+------------------------+------------------------+
+| **Release** | asset-bundle hash or | Tags every browser |
+| | deploy SHA | event. May differ from |
+| | | the Odoo Python |
+| | | release. |
++-----------------+------------------------+------------------------+
+
+No Odoo restart required — changes take effect on the next page load.
+
+**(b) Fallback — shared DSN via ``odoo.conf`` ``[sentry]`` section.** If
+the Connection fields above are left blank, the controller reads from
+the same ``[sentry]`` section the OCA server-side ``sentry`` module
+uses:
+
+.. code:: ini
+
+ [sentry]
+ sentry_dsn = https://@sentry.example.com/
+ sentry_release = 1.3.2
+ sentry_environment = production
+
+This path is convenient for single-project deployments that want both
+backend Python events and browser JavaScript events going to the same
+Sentry project. Editing ``odoo.conf`` requires an Odoo restart.
+
+The UI value always wins when both are set.
+
+2. Settings → General Settings → Sentry Browser Monitoring
+----------------------------------------------------------
+
+Each tier is independently toggleable:
+
+Tier 0 — Capture browser errors (recommended default once a DSN is set)
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Loads ``bundle.min.js`` (~30KB gzipped). Wires ``window.onerror`` and
+``window.onunhandledrejection``. Sends events with the logged-in user's
+id + email as ``Sentry.setUser(...)``.
+
+Tier 1 — Performance monitoring
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Enables ``BrowserTracing``. Auto-instruments fetch / XHR, navigation
+timing, and long-task observer. **Adds roughly 5–10% per-request
+overhead at sample rate 1.0** plus extra bandwidth per traced request.
+Recommended in production: ``0.05`` or below.
+
+Tier 2 — Session replay
+~~~~~~~~~~~~~~~~~~~~~~~
+
+Enables ``@sentry/replay``. **Adds ~100KB to every page** and records
+DOM mutations + console + network activity. Strongly recommend
+``Healthy-session sample = 0.0`` and ``On-error sample = 1.0`` so
+recording only kicks in for sessions that already hit an error.
+
+Tier 3 — Optional extras
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+- **User feedback widget** — adds a feedback button.
+- **Browser CPU profiling** — captures JS Self-Profiling samples for
+ traced transactions. Has its own sample-rate slider. **Requires the
+ page to be served with a ``Document-Policy: js-profiling`` HTTP
+ header.** See "Browser profiling — extra setup" below.
+- **Console-log capture** — uploads ``console.log`` / ``console.warn``
+ calls as Sentry Log entries. Spammy without filtering.
+
+3. User preferences → Privacy — per-user session-replay opt-out
+---------------------------------------------------------------
+
+Each user can disable session replay for their own sessions, regardless
+of the database-wide Tier 2 setting. The browser SDK still loads (the
+bundle URL doesn't change), but the Replay integration is never
+registered for the opted-out user — no DOM observer, no recording.
+
+To enable: open the user's profile (top-right avatar → My Profile →
+Preferences → Privacy) and check **Disable Sentry session replay**.
+
+The toggle is self-writeable: users can manage it without administrator
+help.
+
+What leaves the server per user: events carry the numeric user id plus
+the user's ``res.groups`` names and categories as the ``odoo.groups`` /
+``odoo.category`` tags (for triage filtering — e.g. admin vs portal). No
+email or display name is sent; replay masking covers all text, inputs
+and media by default. If group names are sensitive in your deployment,
+keep in mind they are delivered to whatever Sentry instance the DSN
+points at.
+
+4. OWL component context on backend errors
+------------------------------------------
+
+When the OCA ``sentry_client`` module is installed and Tier 0 is on, any
+OWL-component-raised exception in the backend (``/odoo/*``) is captured
+to Sentry with two extra fields:
+
+- ``tags.owl = true``
+- ``extra.component_tree`` — the OWL component path of the failing
+ render
+
+This complements (not replaces) Odoo's standard *"Oops!"* dialog — both
+fire side by side. No configuration needed; the handler registers
+automatically when the module is installed.
+
+Browser profiling — extra setup
+-------------------------------
+
+The JS Self-Profiling API requires the browser to receive a permission
+header on the document HTML response:
+
+::
+
+ Document-Policy: js-profiling
+
+Odoo's default web responses do not emit this header. You'll need to add
+it at your reverse proxy. nginx example:
+
+.. code:: nginx
+
+ location /odoo {
+ proxy_pass http://odoo:8069;
+ add_header Document-Policy "js-profiling";
+ }
+
+Without the header, the Profiling integration registers cleanly and
+sends profile payloads, but they will be empty — no client-side error,
+just no useful data in Sentry's Profiling tab.
+
+Vendored Sentry SDK
+-------------------
+
+The browser SDK ships **vendored inside the module** under
+``sentry_client/static/lib/sentry//``. The default **Sentry SDK
+source URL** points at this in-module path, so the browser loads the SDK
+from the same origin as Odoo — no traffic to ``browser.sentry-cdn.com``,
+no air-gap workarounds needed.
+
+To bump the vendored version, run the refresh script:
+
+.. code:: bash
+
+ cd sentry_client/
+ ./scripts/refresh-vendor-bundle.sh 10.55.0 # whatever you want
+ git add static/lib/sentry/10.55.0/
+ git commit -m "[IMP] sentry_client: bump vendored SDK to 10.55.0"
+
+The script downloads each bundle from ``browser.sentry-cdn.com``,
+verifies it against Sentry's published SHA-384 SRI hash, drops the
+LICENSE file, and writes a SHA256SUMS for reviewers. After committing,
+update the **Sentry SDK version** in Settings → General Settings →
+Sentry Browser Monitoring to match.
+
+To revert to the public CDN at runtime (e.g. for quick A/B testing),
+override **Sentry SDK source URL** to ``https://browser.sentry-cdn.com``
+in the Settings page.
+
+Sentry server compatibility
+---------------------------
+
+The browser SDK talks to whatever Sentry instance you point the DSN at —
+either sentry.io or a self-hosted instance. Feature support depends on
+the Sentry server version:
+
++----------------------+----------------------+----------------------+
+| Feature | Minimum Sentry | Notes |
+| | server | |
++======================+======================+======================+
+| Tier 0 — error | v9.0+ | Basic event ingest, |
+| capture | | supported by every |
+| | | modern Sentry. |
++----------------------+----------------------+----------------------+
+| Tier 1 — performance | v10.0+ | The tracing UI |
+| / tracing | | shipped in Sentry |
+| | | 10. |
++----------------------+----------------------+----------------------+
+| Tier 2 — session | v22.10.0+ (Oct 2022) | Replay ingest was |
+| replay | + feature flag | introduced in |
+| | | self-hosted 22.10. |
+| | | The feature must |
+| | | also be enabled on |
+| | | the server: set |
+| | | ``SENTRY_FEATURES[ |
+| | | "organizations:sessi |
+| | | on-replay"] = True`` |
+| | | (and ``…-ui``, |
+| | | ``…-re |
+| | | cording-scrubbing``) |
+| | | in |
+| | | ``sentry.conf.py``, |
+| | | then restart ``web`` |
+| | | + |
+| | | ``ingest- |
+| | | replay-recordings``. |
+| | | Without the flag, |
+| | | browser envelopes |
+| | | arrive at |
+| | | `` |
+| | | /api//envelope/`` |
+| | | but are silently |
+| | | discarded — no UI |
+| | | surface, no error. |
++----------------------+----------------------+----------------------+
+| Tier 3 — feedback | v23.6.0+ (Jun 2023) | The modern |
+| widget | | programmatic |
+| | | feedback API. Older |
+| | | versions still work |
+| | | with the legacy |
+| | | ``Sentr |
+| | | y.showReportDialog`` |
+| | | path, which this |
+| | | module does not use. |
++----------------------+----------------------+----------------------+
+| Tier 3 — browser | v24.0+ (Jan 2024) | Plus the |
+| profiling | | ``Document-Po |
+| | | licy: js-profiling`` |
+| | | header (see above). |
++----------------------+----------------------+----------------------+
+| Tier 3 — console-log | v25.0+ (Mar 2025) | Sentry Logs API. |
+| capture | | Server versions |
+| | | before v25 will |
+| | | ingest the events as |
+| | | a generic log |
+| | | envelope; the |
+| | | dedicated Logs UI |
+| | | requires v25+. |
++----------------------+----------------------+----------------------+
+
+For sentry.io: all features are always available.
+
+For self-hosted: check your tag at ``/opt/sentry/install/_version.sh``
+(or ``docker exec sentry --version``). If a feature you've
+enabled isn't supported by your Sentry server, the browser SDK still
+sends the envelope but the server discards it — no client-side error.
+
+Usage
+=====
+
+Once Tier 0 is enabled and a DSN is in ``[sentry]``, the next page load
+injects the (vendored) Sentry browser SDK and starts capturing errors.
+No further user action needed.
+
+To verify the integration:
+
+1. Open the browser dev tools console on any Odoo page.
+2. Run ``throw new Error("sentry_client smoke test")``.
+3. The error appears in your Sentry project within a few seconds, tagged
+ with your Odoo ``user.id``, ``release``, and ``environment``.
+
+Per-user opt-out
+----------------
+
+Top-right avatar → My Profile → Preferences → Privacy → **Disable Sentry
+session replay**. Saves to your own user record. The opt-out only
+suppresses session replay; basic error capture (Tier 0) still fires.
+
+OWL component context
+---------------------
+
+When the backend OWL stack raises an exception (the usual "Oops!" dialog
+you see in the Odoo web client), the resulting Sentry event
+automatically carries:
+
+- ``tags.owl = true``
+- ``extra.component_tree`` — the OWL component path
+
+No configuration needed — the OCA ``sentry_client`` module registers an
+entry in ``@web/core/error_handlers`` at install time. Standard Odoo
+error UX is unaffected.
+
+Known issues / Roadmap
+======================
+
+- **Server-side distributed-trace propagation** — ``release`` and
+ ``environment`` are shared with the OCA ``sentry`` server-side module
+ by convention (same ``[sentry]`` section), and the browser already
+ sends the right user context. Full distributed tracing (server span ⇄
+ browser span correlation) would need OpenTelemetry hooks in the
+ server-side ``sentry`` module too — out of scope for this module; will
+ go in a follow-up PR against ``sentry/``.
+- **OWL error-boundary depth** — the current handler captures the
+ failing component tree + props. Could also enrich with the action
+ context (active model, record IDs, view type) by reading
+ ``env.services.action.currentController``. Optional polish.
+- **Asset-bundle profiling preload** — the JS Self-Profiling API needs
+ the ``Document-Policy: js-profiling`` HTTP header on the document
+ response, which Odoo doesn't emit by default. CONFIGURE.md documents
+ the nginx workaround; a small ``ir.http.dispatch`` hook in this module
+ could set the header conditionally when Tier 3 profiling is on.
+
+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
+-------
+
+* Ledoent
+
+Contributors
+------------
+
+- Don Kendall
+
+Other credits
+-------------
+
+The development of this module is led by
+`Ledoent `__.
+
+Companion to the OCA ```sentry`` <../sentry>`__ module for server-side
+error capture.
+
+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-dnplkndll| image:: https://github.com/dnplkndll.png?size=40px
+ :target: https://github.com/dnplkndll
+ :alt: dnplkndll
+
+Current `maintainer `__:
+
+|maintainer-dnplkndll|
+
+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/sentry_client/__init__.py b/sentry_client/__init__.py
new file mode 100644
index 00000000000..91c5580fed3
--- /dev/null
+++ b/sentry_client/__init__.py
@@ -0,0 +1,2 @@
+from . import controllers
+from . import models
diff --git a/sentry_client/__manifest__.py b/sentry_client/__manifest__.py
new file mode 100644
index 00000000000..b564f2abeda
--- /dev/null
+++ b/sentry_client/__manifest__.py
@@ -0,0 +1,32 @@
+# Copyright 2026 Ledoent
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+{
+ "name": "Sentry — Browser SDK",
+ "summary": "Capture Odoo web-client JS errors in Sentry, "
+ "with tiered opt-in for tracing and session replay",
+ "version": "18.0.1.0.0",
+ "category": "Extra Tools",
+ "website": "https://github.com/OCA/server-tools",
+ "author": "Ledoent, Odoo Community Association (OCA)",
+ "maintainers": ["dnplkndll"],
+ "license": "AGPL-3",
+ "depends": ["web"],
+ "data": [
+ "security/ir.model.access.csv",
+ "views/res_config_settings_views.xml",
+ "views/res_users_views.xml",
+ ],
+ "assets": {
+ "web.assets_backend": [
+ "sentry_client/static/src/js/sentry_loader.js",
+ "sentry_client/static/src/js/owl_error_boundary.esm.js",
+ "sentry_client/static/src/js/feedback_systray.esm.js",
+ "sentry_client/static/src/xml/feedback_systray.xml",
+ ],
+ "web.assets_frontend": [
+ "sentry_client/static/src/js/sentry_loader.js",
+ ],
+ },
+ "installable": True,
+ "application": False,
+}
diff --git a/sentry_client/controllers/__init__.py b/sentry_client/controllers/__init__.py
new file mode 100644
index 00000000000..12a7e529b67
--- /dev/null
+++ b/sentry_client/controllers/__init__.py
@@ -0,0 +1 @@
+from . import main
diff --git a/sentry_client/controllers/main.py b/sentry_client/controllers/main.py
new file mode 100644
index 00000000000..e660c588d1a
--- /dev/null
+++ b/sentry_client/controllers/main.py
@@ -0,0 +1,183 @@
+# Copyright 2026 Ledoent
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+import logging
+from configparser import ConfigParser
+
+from odoo import http
+from odoo.http import request
+from odoo.tools import config as odoo_config
+
+_logger = logging.getLogger(__name__)
+
+
+def _read_sentry_section():
+ """Read the [sentry] section either from server_environment (if installed)
+ or directly from odoo.conf. Used as a fallback when the browser DSN /
+ release / environment are NOT set via `ir.config_parameter`; this keeps a
+ single-project deployment working out of the box when the same DSN is
+ shared with the server-side OCA `sentry` module.
+ """
+ try:
+ from odoo.addons.server_environment import serv_config
+
+ if serv_config.has_section("sentry"):
+ return dict(serv_config["sentry"])
+ except ImportError:
+ _logger.debug(
+ "server_environment not installed; reading [sentry] from odoo.conf"
+ )
+ cfg_path = odoo_config.get("config")
+ if not cfg_path:
+ return {}
+ cp = ConfigParser(interpolation=None)
+ cp.read(cfg_path)
+ if cp.has_section("sentry"):
+ return dict(cp["sentry"])
+ return {}
+
+
+def _bundle_name(tracing, replay, feedback):
+ """Compose the Sentry browser SDK bundle filename for a given tier mix.
+
+ Sentry's CDN ships `bundle.{tracing,replay,feedback}.min.js` but does NOT
+ ship `bundle.tracing.feedback.min.js` — when tracing+feedback are both on
+ without replay, we fall back to the tracing+replay+feedback bundle. The
+ extra replay code is inert without our integration registering it, so the
+ only cost is bandwidth.
+ """
+ if feedback and tracing and not replay:
+ replay = True
+ parts = ["bundle"]
+ if tracing or replay:
+ parts.append("tracing")
+ if replay:
+ parts.append("replay")
+ if feedback:
+ parts.append("feedback")
+ return ".".join(parts) + ".min.js"
+
+
+class SentryClientController(http.Controller):
+ @http.route(
+ "/sentry_client/config.json",
+ type="http",
+ auth="public",
+ methods=["GET"],
+ csrf=False,
+ sitemap=False,
+ )
+ def config(self, **kwargs):
+ """Return the runtime config for the browser SDK.
+
+ Public on purpose — portal users and the login page need it before
+ authentication. The DSN is a public DSN; only behaviour flags and the
+ authenticated user's *numeric* id leave the server here. Email + name
+ come from `window.odoo.session_info` on the client side, which is
+ already gated by the session cookie.
+ """
+ params = request.env["ir.config_parameter"].sudo()
+ get = params.get_param
+
+ def _bool(key):
+ return get(key, "False") == "True"
+
+ def _rate(key, default="0.0"):
+ try:
+ return max(0.0, min(1.0, float(get(key, default))))
+ except (TypeError, ValueError):
+ return float(default)
+
+ if not _bool("sentry_client.enabled"):
+ return request.make_json_response({"enabled": False})
+
+ # DSN / environment / release resolution order:
+ # 1. `ir.config_parameter` (UI-settable, per-database)
+ # 2. `[sentry]` section of odoo.conf (server-admin, shared with the
+ # OCA `sentry` server-side module)
+ # The two paths exist so platform-split deployments can give the
+ # browser its own Sentry project (Sentry's recommended setup — JS
+ # platform separate from the Python platform) while single-project
+ # deployments still work without UI clicks.
+ sentry_conf = _read_sentry_section()
+
+ def _resolve(icp_key, *conf_keys):
+ val = get(icp_key)
+ if val:
+ return val
+ for ck in conf_keys:
+ v = sentry_conf.get(ck)
+ if v:
+ return v
+ return None
+
+ dsn = _resolve("sentry_client.browser_dsn", "sentry_dsn", "dsn")
+ if not dsn:
+ return request.make_json_response({"enabled": False})
+
+ tracing = _bool("sentry_client.tier1_tracing_enabled")
+ replay = _bool("sentry_client.tier2_replay_enabled")
+ feedback = _bool("sentry_client.tier3_feedback_enabled")
+ profiling = _bool("sentry_client.tier3_profiling_enabled")
+ logs = _bool("sentry_client.tier3_logs_enabled")
+
+ cdn_base = get(
+ "sentry_client.cdn_base", "/sentry_client/static/lib/sentry"
+ ).rstrip("/")
+ cdn_version = get("sentry_client.cdn_version", "10.53.1")
+ bundle_url = (
+ f"{cdn_base}/{cdn_version}/{_bundle_name(tracing, replay, feedback)}"
+ )
+ profiling_addon_url = (
+ f"{cdn_base}/{cdn_version}/browserprofiling.min.js" if profiling else None
+ )
+
+ payload = {
+ "enabled": True,
+ "dsn": dsn,
+ "release": _resolve("sentry_client.release", "sentry_release", "release"),
+ "environment": _resolve(
+ "sentry_client.environment", "sentry_environment", "environment"
+ ),
+ "bundle_url": bundle_url,
+ "profiling_addon_url": profiling_addon_url,
+ "integrations": {
+ "tracing": tracing,
+ "replay": replay,
+ "feedback": feedback,
+ "profiling": profiling,
+ "logs": logs,
+ },
+ "traces_sample_rate": _rate("sentry_client.tier1_traces_sample_rate"),
+ "replay_session_sample_rate": _rate(
+ "sentry_client.tier2_session_sample_rate"
+ ),
+ "replay_error_sample_rate": _rate(
+ "sentry_client.tier2_error_sample_rate", "1.0"
+ ),
+ "profiles_sample_rate": _rate("sentry_client.tier3_profiles_sample_rate"),
+ }
+
+ user = request.env.user
+ if user and not user._is_public():
+ payload["user_id"] = user.id
+ if replay:
+ payload["replay_optout"] = bool(user.sentry_client_replay_optout)
+ # Ship role primitives so the browser SDK can tag every event
+ # with the user's groups + app categories. Downstream training
+ # corpora bucket sessions by these tags to keep "sales rep"
+ # behaviour separate from "accountant" behaviour. Group full
+ # names ("Sales / Salesperson") are easier to grok than xmlids
+ # in Sentry tag-search.
+ all_groups = user.sudo().groups_id
+ payload["groups"] = all_groups.mapped("full_name") or all_groups.mapped(
+ "name"
+ )
+ payload["categories"] = sorted(
+ {
+ cat.name
+ for cat in all_groups.mapped("category_id")
+ if cat and cat.name
+ }
+ )
+
+ return request.make_json_response(payload)
diff --git a/sentry_client/models/__init__.py b/sentry_client/models/__init__.py
new file mode 100644
index 00000000000..ef6d50929f3
--- /dev/null
+++ b/sentry_client/models/__init__.py
@@ -0,0 +1,2 @@
+from . import res_config_settings
+from . import res_users
diff --git a/sentry_client/models/res_config_settings.py b/sentry_client/models/res_config_settings.py
new file mode 100644
index 00000000000..aa49c5a9c92
--- /dev/null
+++ b/sentry_client/models/res_config_settings.py
@@ -0,0 +1,161 @@
+# Copyright 2026 Ledoent
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+from odoo import api, fields, models
+from odoo.exceptions import ValidationError
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = "res.config.settings"
+
+ @api.constrains("sentry_client_cdn_base")
+ def _check_sentry_client_cdn_base(self):
+ for rec in self:
+ url = rec.sentry_client_cdn_base or ""
+ if url and not (
+ url.startswith("/")
+ or url.startswith("http://")
+ or url.startswith("https://")
+ ):
+ raise ValidationError(
+ self.env._(
+ "Sentry SDK source URL must start with '/', 'http://', "
+ "or 'https://'. Got: %(url)s",
+ url=url,
+ )
+ )
+
+ @api.constrains("sentry_client_browser_dsn")
+ def _check_sentry_client_browser_dsn(self):
+ for rec in self:
+ dsn = (rec.sentry_client_browser_dsn or "").strip()
+ if not dsn:
+ continue
+ # Sentry DSNs look like https://@[:port]/
+ # Public DSNs are safe to embed in client code per Sentry's docs;
+ # we still validate shape to fail fast on typos.
+ if not (dsn.startswith("http://") or dsn.startswith("https://")):
+ raise ValidationError(
+ self.env._(
+ "Sentry Browser DSN must start with 'http://' or "
+ "'https://'. Got: %(dsn)s",
+ dsn=dsn,
+ )
+ )
+ if "@" not in dsn or "/" not in dsn.split("@", 1)[1]:
+ raise ValidationError(
+ self.env._(
+ "Sentry Browser DSN must be of the form "
+ "https://@/. "
+ "Got: %(dsn)s",
+ dsn=dsn,
+ )
+ )
+
+ # Connection — DSN and tag overrides. All three are optional; when blank,
+ # the controller falls back to the [sentry] section of odoo.conf so a
+ # single-project deployment shared with the OCA `sentry` server-side
+ # module keeps working without UI clicks.
+ sentry_client_browser_dsn = fields.Char(
+ string="Browser DSN",
+ config_parameter="sentry_client.browser_dsn",
+ help="Public Sentry DSN for the browser project. Leave blank to "
+ "reuse the DSN from the [sentry] section of odoo.conf. Sentry "
+ "recommends a separate project per platform (Python vs. "
+ "JavaScript-Browser); set this to that project's DSN. Browser "
+ "DSNs are public by design and safe to expose to end users.",
+ )
+ sentry_client_environment = fields.Char(
+ string="Environment tag",
+ config_parameter="sentry_client.environment",
+ help="Tags every browser event with this environment "
+ "(e.g. 'production-web', 'staging-web'). Leave blank to inherit "
+ "from the [sentry] section of odoo.conf.",
+ )
+ sentry_client_release = fields.Char(
+ string="Release tag",
+ config_parameter="sentry_client.release",
+ help="Tags every browser event with this release identifier "
+ "(e.g. the asset-bundle hash or a deploy SHA). Leave blank to "
+ "inherit from the [sentry] section of odoo.conf.",
+ )
+
+ # Tier 0 — always-free essentials
+ sentry_client_enabled = fields.Boolean(
+ string="Enable browser error reporting",
+ config_parameter="sentry_client.enabled",
+ help="When enabled and a DSN is configured (either above or in the "
+ "[sentry] section of odoo.conf), the Sentry browser SDK is loaded "
+ "into the Odoo web client and captures uncaught JS errors and "
+ "unhandled promise rejections.",
+ )
+ sentry_client_cdn_base = fields.Char(
+ string="Sentry SDK source URL",
+ config_parameter="sentry_client.cdn_base",
+ default="/sentry_client/static/lib/sentry",
+ help="Where the Sentry browser SDK bundle is loaded from. Defaults "
+ "to the bundle vendored inside this module so no external network "
+ "call is needed. Override to point at a mirror or back at the "
+ "public CDN at https://browser.sentry-cdn.com.",
+ )
+ sentry_client_cdn_version = fields.Char(
+ string="Sentry SDK version",
+ config_parameter="sentry_client.cdn_version",
+ default="10.53.1",
+ )
+
+ # Tier 1 — performance monitoring
+ sentry_client_tier1_tracing_enabled = fields.Boolean(
+ string="Enable performance monitoring (Tier 1)",
+ config_parameter="sentry_client.tier1_tracing_enabled",
+ )
+ sentry_client_tier1_traces_sample_rate = fields.Float(
+ string="Traces sample rate",
+ config_parameter="sentry_client.tier1_traces_sample_rate",
+ default=0.0,
+ help="Fraction of requests to record performance traces for. "
+ "0.0 = none, 1.0 = all. Recommended in production: 0.05 or below.",
+ )
+ # Tier 2 — session replay
+ sentry_client_tier2_replay_enabled = fields.Boolean(
+ string="Enable session replay (Tier 2)",
+ config_parameter="sentry_client.tier2_replay_enabled",
+ )
+ sentry_client_tier2_session_sample_rate = fields.Float(
+ string="Healthy-session sample rate",
+ config_parameter="sentry_client.tier2_session_sample_rate",
+ default=0.0,
+ help="Fraction of HEALTHY user sessions to record. Keep at 0.0 "
+ "unless you have a specific UX debugging need.",
+ )
+ sentry_client_tier2_error_sample_rate = fields.Float(
+ string="On-error session sample rate",
+ config_parameter="sentry_client.tier2_error_sample_rate",
+ default=1.0,
+ help="Fraction of sessions that hit an error to record. 1.0 means "
+ "every errored session is captured for replay.",
+ )
+ # Tier 3 — niche extras
+ sentry_client_tier3_feedback_enabled = fields.Boolean(
+ string="Enable user feedback widget",
+ config_parameter="sentry_client.tier3_feedback_enabled",
+ )
+ sentry_client_tier3_profiling_enabled = fields.Boolean(
+ string="Enable browser CPU profiling",
+ config_parameter="sentry_client.tier3_profiling_enabled",
+ help="Captures JS Self-Profiling samples for traced transactions. "
+ "Requires the page to be served with a "
+ "`Document-Policy: js-profiling` HTTP header — without it the "
+ "integration registers but never collects samples. See CONFIGURE.",
+ )
+ sentry_client_tier3_profiles_sample_rate = fields.Float(
+ string="Profiles sample rate",
+ config_parameter="sentry_client.tier3_profiles_sample_rate",
+ default=0.0,
+ help="Fraction of traced transactions for which to also capture a "
+ "browser CPU profile. 0.0 = none, 1.0 = all. Recommended in "
+ "production: 0.05 or below.",
+ )
+ sentry_client_tier3_logs_enabled = fields.Boolean(
+ string="Capture console logs",
+ config_parameter="sentry_client.tier3_logs_enabled",
+ )
diff --git a/sentry_client/models/res_users.py b/sentry_client/models/res_users.py
new file mode 100644
index 00000000000..ed4c91716f1
--- /dev/null
+++ b/sentry_client/models/res_users.py
@@ -0,0 +1,24 @@
+# Copyright 2026 Ledoent
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+from odoo import fields, models
+
+
+class ResUsers(models.Model):
+ _inherit = "res.users"
+
+ sentry_client_replay_optout = fields.Boolean(
+ string="Disable Sentry session replay",
+ help="Sentry session replay records DOM changes, console activity, "
+ "and network requests for any session that hits an error. "
+ "Enable this to keep that recording off for your own sessions, "
+ "regardless of the database-wide Tier 2 toggle. Server-wide error "
+ "capture (Tier 0) is unaffected.",
+ )
+
+ @property
+ def SELF_READABLE_FIELDS(self):
+ return super().SELF_READABLE_FIELDS + ["sentry_client_replay_optout"]
+
+ @property
+ def SELF_WRITEABLE_FIELDS(self):
+ return super().SELF_WRITEABLE_FIELDS + ["sentry_client_replay_optout"]
diff --git a/sentry_client/pyproject.toml b/sentry_client/pyproject.toml
new file mode 100644
index 00000000000..4231d0cccb3
--- /dev/null
+++ b/sentry_client/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["whool"]
+build-backend = "whool.buildapi"
diff --git a/sentry_client/readme/CONFIGURE.md b/sentry_client/readme/CONFIGURE.md
new file mode 100644
index 00000000000..e7b5a8e7d84
--- /dev/null
+++ b/sentry_client/readme/CONFIGURE.md
@@ -0,0 +1,173 @@
+## 1. Pick where the DSN comes from
+
+There are two ways to point the browser SDK at a Sentry project. Use **one**:
+
+**(a) Recommended — separate browser DSN via the Settings UI.** Sentry's own
+guidance is one project per platform: a Python project for backend errors
+and a JavaScript-Browser project for client-side errors. Set the dedicated
+browser DSN under **Settings → General Settings → Sentry Browser Monitoring
+→ Connection**:
+
+| Field | Example | Notes |
+|---|---|---|
+| **Browser DSN** | `https://@sentry.example.com/` | Public DSN of the JavaScript project. Safe to embed in client code per Sentry's docs. |
+| **Environment** | `production-web` | Tags every browser event. May differ from the backend env tag. |
+| **Release** | asset-bundle hash or deploy SHA | Tags every browser event. May differ from the Odoo Python release. |
+
+No Odoo restart required — changes take effect on the next page load.
+
+**(b) Fallback — shared DSN via `odoo.conf` `[sentry]` section.** If the
+Connection fields above are left blank, the controller reads from the same
+`[sentry]` section the OCA server-side `sentry` module uses:
+
+```ini
+[sentry]
+sentry_dsn = https://@sentry.example.com/
+sentry_release = 1.3.2
+sentry_environment = production
+```
+
+This path is convenient for single-project deployments that want both
+backend Python events and browser JavaScript events going to the same
+Sentry project. Editing `odoo.conf` requires an Odoo restart.
+
+The UI value always wins when both are set.
+
+## 2. Settings → General Settings → Sentry Browser Monitoring
+
+Each tier is independently toggleable:
+
+### Tier 0 — Capture browser errors (recommended default once a DSN is set)
+
+Loads `bundle.min.js` (~30KB gzipped). Wires `window.onerror` and
+`window.onunhandledrejection`. Sends events with the logged-in user's id +
+email as `Sentry.setUser(...)`.
+
+### Tier 1 — Performance monitoring
+
+Enables `BrowserTracing`. Auto-instruments fetch / XHR, navigation timing,
+and long-task observer. **Adds roughly 5–10% per-request overhead at sample
+rate 1.0** plus extra bandwidth per traced request. Recommended in
+production: `0.05` or below.
+
+### Tier 2 — Session replay
+
+Enables `@sentry/replay`. **Adds ~100KB to every page** and records DOM
+mutations + console + network activity. Strongly recommend
+`Healthy-session sample = 0.0` and `On-error sample = 1.0` so recording
+only kicks in for sessions that already hit an error.
+
+### Tier 3 — Optional extras
+
+* **User feedback widget** — adds a feedback button.
+* **Browser CPU profiling** — captures JS Self-Profiling samples for
+ traced transactions. Has its own sample-rate slider. **Requires the
+ page to be served with a `Document-Policy: js-profiling` HTTP header.**
+ See "Browser profiling — extra setup" below.
+* **Console-log capture** — uploads `console.log` / `console.warn` calls
+ as Sentry Log entries. Spammy without filtering.
+
+## 3. User preferences → Privacy — per-user session-replay opt-out
+
+Each user can disable session replay for their own sessions, regardless of
+the database-wide Tier 2 setting. The browser SDK still loads (the bundle
+URL doesn't change), but the Replay integration is never registered for
+the opted-out user — no DOM observer, no recording.
+
+To enable: open the user's profile (top-right avatar → My Profile →
+Preferences → Privacy) and check **Disable Sentry session replay**.
+
+The toggle is self-writeable: users can manage it without administrator
+help.
+
+What leaves the server per user: events carry the numeric user id plus the
+user's `res.groups` names and categories as the `odoo.groups` /
+`odoo.category` tags (for triage filtering — e.g. admin vs portal). No
+email or display name is sent; replay masking covers all text, inputs and
+media by default. If group names are sensitive in your deployment, keep in
+mind they are delivered to whatever Sentry instance the DSN points at.
+
+## 4. OWL component context on backend errors
+
+When the OCA `sentry_client` module is installed and Tier 0 is on, any
+OWL-component-raised exception in the backend (`/odoo/*`) is captured to
+Sentry with two extra fields:
+
+- `tags.owl = true`
+- `extra.component_tree` — the OWL component path of the failing render
+
+This complements (not replaces) Odoo's standard *"Oops!"* dialog — both
+fire side by side. No configuration needed; the handler registers
+automatically when the module is installed.
+
+## Browser profiling — extra setup
+
+The JS Self-Profiling API requires the browser to receive a permission
+header on the document HTML response:
+
+```
+Document-Policy: js-profiling
+```
+
+Odoo's default web responses do not emit this header. You'll need to add
+it at your reverse proxy. nginx example:
+
+```nginx
+location /odoo {
+ proxy_pass http://odoo:8069;
+ add_header Document-Policy "js-profiling";
+}
+```
+
+Without the header, the Profiling integration registers cleanly and
+sends profile payloads, but they will be empty — no client-side error,
+just no useful data in Sentry's Profiling tab.
+
+## Vendored Sentry SDK
+
+The browser SDK ships **vendored inside the module** under
+`sentry_client/static/lib/sentry//`. The default
+**Sentry SDK source URL** points at this in-module path, so the browser
+loads the SDK from the same origin as Odoo — no traffic to
+`browser.sentry-cdn.com`, no air-gap workarounds needed.
+
+To bump the vendored version, run the refresh script:
+
+```bash
+cd sentry_client/
+./scripts/refresh-vendor-bundle.sh 10.55.0 # whatever you want
+git add static/lib/sentry/10.55.0/
+git commit -m "[IMP] sentry_client: bump vendored SDK to 10.55.0"
+```
+
+The script downloads each bundle from `browser.sentry-cdn.com`, verifies
+it against Sentry's published SHA-384 SRI hash, drops the LICENSE file,
+and writes a SHA256SUMS for reviewers. After committing, update the
+**Sentry SDK version** in Settings → General Settings → Sentry Browser
+Monitoring to match.
+
+To revert to the public CDN at runtime (e.g. for quick A/B testing),
+override **Sentry SDK source URL** to
+`https://browser.sentry-cdn.com` in the Settings page.
+
+## Sentry server compatibility
+
+The browser SDK talks to whatever Sentry instance you point the DSN at —
+either sentry.io or a self-hosted instance. Feature support depends on
+the Sentry server version:
+
+| Feature | Minimum Sentry server | Notes |
+|---|---|---|
+| Tier 0 — error capture | v9.0+ | Basic event ingest, supported by every modern Sentry. |
+| Tier 1 — performance / tracing | v10.0+ | The tracing UI shipped in Sentry 10. |
+| Tier 2 — session replay | v22.10.0+ (Oct 2022) + feature flag | Replay ingest was introduced in self-hosted 22.10. The feature must also be enabled on the server: set `SENTRY_FEATURES["organizations:session-replay"] = True` (and `…-ui`, `…-recording-scrubbing`) in `sentry.conf.py`, then restart `web` + `ingest-replay-recordings`. Without the flag, browser envelopes arrive at `/api//envelope/` but are silently discarded — no UI surface, no error. |
+| Tier 3 — feedback widget | v23.6.0+ (Jun 2023) | The modern programmatic feedback API. Older versions still work with the legacy `Sentry.showReportDialog` path, which this module does not use. |
+| Tier 3 — browser profiling | v24.0+ (Jan 2024) | Plus the `Document-Policy: js-profiling` header (see above). |
+| Tier 3 — console-log capture | v25.0+ (Mar 2025) | Sentry Logs API. Server versions before v25 will ingest the events as a generic log envelope; the dedicated Logs UI requires v25+. |
+
+For sentry.io: all features are always available.
+
+For self-hosted: check your tag at `/opt/sentry/install/_version.sh` (or
+`docker exec sentry --version`). If a feature you've enabled
+isn't supported by your Sentry server, the browser SDK still sends the
+envelope but the server discards it — no client-side error.
diff --git a/sentry_client/readme/CONTRIBUTORS.md b/sentry_client/readme/CONTRIBUTORS.md
new file mode 100644
index 00000000000..9ce0e86b9ee
--- /dev/null
+++ b/sentry_client/readme/CONTRIBUTORS.md
@@ -0,0 +1 @@
+- Don Kendall \
diff --git a/sentry_client/readme/CREDITS.md b/sentry_client/readme/CREDITS.md
new file mode 100644
index 00000000000..7b335f4f408
--- /dev/null
+++ b/sentry_client/readme/CREDITS.md
@@ -0,0 +1,4 @@
+The development of this module is led by [Ledoent](https://ledoweb.com).
+
+Companion to the OCA [`sentry`](../sentry) module for server-side error
+capture.
diff --git a/sentry_client/readme/DESCRIPTION.md b/sentry_client/readme/DESCRIPTION.md
new file mode 100644
index 00000000000..78072cc2af6
--- /dev/null
+++ b/sentry_client/readme/DESCRIPTION.md
@@ -0,0 +1,21 @@
+Capture uncaught JS errors and unhandled promise rejections in the Odoo web
+client to Sentry, with optional Performance Monitoring (BrowserTracing),
+Session Replay, Browser CPU Profiling, and Console-log capture tiers
+behind explicit opt-in toggles.
+
+The Sentry browser SDK ships **vendored inside the module** — no external
+CDN call, air-gapped friendly out of the box.
+
+**Standalone:** works on its own. Reads DSN / release / environment from
+the `[sentry]` section of `odoo.conf`. Captures browser-side errors only.
+
+**Better together with `sentry`:** install alongside the server-side
+[`sentry`](../sentry) module to cluster client and server errors for the
+same user / release / environment into one Sentry issue. Both modules
+share the same `[sentry]` config section by convention — fill in the
+section once and client + server events land in the same Sentry project.
+
+Each tier above Tier 0 is **off by default** and surfaces an in-form
+warning about its perf cost when enabled. Sample rates are sliders so
+admins can dial behaviour without a server restart. Individual users can
+opt out of session replay via their own preferences page.
diff --git a/sentry_client/readme/ROADMAP.md b/sentry_client/readme/ROADMAP.md
new file mode 100644
index 00000000000..ccd8b89682c
--- /dev/null
+++ b/sentry_client/readme/ROADMAP.md
@@ -0,0 +1,16 @@
+* **Server-side distributed-trace propagation** — `release` and
+ `environment` are shared with the OCA `sentry` server-side module by
+ convention (same `[sentry]` section), and the browser already sends
+ the right user context. Full distributed tracing (server span ⇄
+ browser span correlation) would need OpenTelemetry hooks in the
+ server-side `sentry` module too — out of scope for this module; will
+ go in a follow-up PR against `sentry/`.
+* **OWL error-boundary depth** — the current handler captures the
+ failing component tree + props. Could also enrich with the action
+ context (active model, record IDs, view type) by reading
+ `env.services.action.currentController`. Optional polish.
+* **Asset-bundle profiling preload** — the JS Self-Profiling API needs
+ the `Document-Policy: js-profiling` HTTP header on the document
+ response, which Odoo doesn't emit by default. CONFIGURE.md documents
+ the nginx workaround; a small `ir.http.dispatch` hook in this module
+ could set the header conditionally when Tier 3 profiling is on.
diff --git a/sentry_client/readme/USAGE.md b/sentry_client/readme/USAGE.md
new file mode 100644
index 00000000000..c7d30ffb7ea
--- /dev/null
+++ b/sentry_client/readme/USAGE.md
@@ -0,0 +1,30 @@
+Once Tier 0 is enabled and a DSN is in `[sentry]`, the next page load injects
+the (vendored) Sentry browser SDK and starts capturing errors. No further
+user action needed.
+
+To verify the integration:
+
+1. Open the browser dev tools console on any Odoo page.
+2. Run `throw new Error("sentry_client smoke test")`.
+3. The error appears in your Sentry project within a few seconds, tagged with
+ your Odoo `user.id`, `release`, and `environment`.
+
+## Per-user opt-out
+
+Top-right avatar → My Profile → Preferences → Privacy →
+**Disable Sentry session replay**. Saves to your own user record. The
+opt-out only suppresses session replay; basic error capture (Tier 0)
+still fires.
+
+## OWL component context
+
+When the backend OWL stack raises an exception (the usual "Oops!" dialog
+you see in the Odoo web client), the resulting Sentry event automatically
+carries:
+
+- `tags.owl = true`
+- `extra.component_tree` — the OWL component path
+
+No configuration needed — the OCA `sentry_client` module registers an
+entry in `@web/core/error_handlers` at install time. Standard Odoo error
+UX is unaffected.
diff --git a/sentry_client/scripts/refresh-vendor-bundle.sh b/sentry_client/scripts/refresh-vendor-bundle.sh
new file mode 100755
index 00000000000..537da01917c
--- /dev/null
+++ b/sentry_client/scripts/refresh-vendor-bundle.sh
@@ -0,0 +1,138 @@
+#!/usr/bin/env bash
+# Copyright 2026 Ledoent
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+#
+# refresh-vendor-bundle.sh — pull the Sentry browser SDK UMD bundles down from
+# browser.sentry-cdn.com, verify them against Sentry's published SRI hashes,
+# and stage them under static/lib/sentry// so the runtime never needs
+# to reach a public CDN.
+#
+# Usage: ./refresh-vendor-bundle.sh [VERSION]
+# VERSION defaults to PINNED_VERSION below. Example: ./refresh-vendor-bundle.sh 10.55.0
+#
+# Run from the module root (sentry_client/), then `git add static/lib/sentry//`
+# and commit.
+
+set -euo pipefail
+
+PINNED_VERSION="10.53.1"
+VERSION="${1:-$PINNED_VERSION}"
+
+CDN="https://browser.sentry-cdn.com"
+REGISTRY="https://release-registry.services.sentry.io/sdks/sentry.javascript.browser/${VERSION}"
+
+OUT_DIR="$(cd "$(dirname "$0")/.." && pwd)/static/lib/sentry/${VERSION}"
+mkdir -p "${OUT_DIR}"
+
+# Bundles to vendor — keep in sync with controllers/main.py::_bundle_name().
+# Each name produces both `.min.js` + `.min.js.map`. Profiling is included in
+# every bundle as of Sentry SDK 10.x — no separate profiling bundle file.
+BUNDLES=(
+ "bundle.min.js"
+ "bundle.tracing.min.js"
+ "bundle.tracing.replay.min.js"
+ "bundle.feedback.min.js"
+ "bundle.tracing.replay.feedback.min.js"
+ # Add-on bundle for browser CPU profiling. Loaded as a second