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