Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
435 changes: 435 additions & 0 deletions sentry_client/README.rst

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions sentry_client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import controllers
from . import models
32 changes: 32 additions & 0 deletions sentry_client/__manifest__.py
Original file line number Diff line number Diff line change
@@ -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": ["base_setup", "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,
}
1 change: 1 addition & 0 deletions sentry_client/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import main
164 changes: 164 additions & 0 deletions sentry_client/controllers/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# Copyright 2026 Ledoent
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import logging
from urllib.parse import urlsplit, urlunsplit

from odoo import http
from odoo.http import request
from odoo.tools import config as odoo_config

_logger = logging.getLogger(__name__)

# The keys the server-side OCA `sentry` module reads on the 18.0 series:
# plain options in odoo.conf's [options] section (the dedicated [sentry]
# section only exists from 19.0).
_SENTRY_CONF_KEYS = ("sentry_dsn", "sentry_environment", "sentry_release")


def _read_sentry_config():
"""Read the server-side `sentry_*` options from the Odoo configuration.

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, which on 18.0 is configured with top-level
`sentry_*` options in odoo.conf.
"""
return {
key: odoo_config.get(key) for key in _SENTRY_CONF_KEYS if odoo_config.get(key)
}


def _public_dsn(dsn):
"""Drop the legacy ``key:secret@`` secret so it never reaches the browser."""
parts = urlsplit(dsn)
userinfo, _, hostport = parts.netloc.rpartition("@")
if ":" not in userinfo:
return dsn
return urlunsplit(parts._replace(netloc=f"{userinfo.split(':', 1)[0]}@{hostport}"))


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_*` options in odoo.conf (server-admin, the same keys
# the OCA `sentry` server-side module reads on 18.0)
# 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_config()

def _resolve(icp_key, conf_key):
return get(icp_key) or sentry_conf.get(conf_key) or None

dsn = _resolve("sentry_client.browser_dsn", "sentry_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": _public_dsn(dsn),
"release": _resolve("sentry_client.release", "sentry_release"),
"environment": _resolve("sentry_client.environment", "sentry_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)
# App categories only: group names identify the user and overrun
# Sentry's 200-character tag limit.
payload["categories"] = sorted(
{cat.name for cat in user.sudo().groups_id.category_id if cat.name}
)

return request.make_json_response(payload)
2 changes: 2 additions & 0 deletions sentry_client/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import res_config_settings
from . import res_users
170 changes: 170 additions & 0 deletions sentry_client/models/res_config_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# Copyright 2026 Ledoent
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from urllib.parse import urlsplit

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://<public_key>@<host>[:port]/<project_id>
# 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://<public_key>@<host>/<project_id>. "
"Got: %(dsn)s",
dsn=dsn,
)
)
if urlsplit(dsn).password:
raise ValidationError(
self.env._(
"Sentry Browser DSN must be a public DSN — it is served "
"to every browser. Remove the ':<secret>' part."
)
)

# Connection — DSN and tag overrides. All three are optional; when blank,
# the controller falls back to the `sentry_*` options in 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 `sentry_dsn` option from 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_*` options in 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_*` options in 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 via the "
"`sentry_dsn` option in 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",
)
Loading
Loading