diff --git a/.coveragerc b/.coveragerc index 41b7b8dcb88..8ca809626a0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -747,7 +747,6 @@ omit = homeassistant/components/sensehat/light.py homeassistant/components/sensehat/sensor.py homeassistant/components/sensibo/climate.py - homeassistant/components/sentry/__init__.py homeassistant/components/serial/sensor.py homeassistant/components/serial_pm/sensor.py homeassistant/components/sesame/lock.py diff --git a/CODEOWNERS b/CODEOWNERS index fa7d79b124e..4eb9b399059 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -361,7 +361,7 @@ homeassistant/components/script/* @home-assistant/core homeassistant/components/search/* @home-assistant/core homeassistant/components/sense/* @kbickar homeassistant/components/sensibo/* @andrey-git -homeassistant/components/sentry/* @dcramer +homeassistant/components/sentry/* @dcramer @frenck homeassistant/components/serial/* @fabaff homeassistant/components/seven_segments/* @fabaff homeassistant/components/seventeentrack/* @bachya diff --git a/homeassistant/components/sentry/__init__.py b/homeassistant/components/sentry/__init__.py index 8ce23248832..56eede297ab 100644 --- a/homeassistant/components/sentry/__init__.py +++ b/homeassistant/components/sentry/__init__.py @@ -1,58 +1,200 @@ """The sentry integration.""" -import logging +import re +from typing import Dict, Union import sentry_sdk +from sentry_sdk.integrations.aiohttp import AioHttpIntegration from sentry_sdk.integrations.logging import LoggingIntegration -import voluptuous as vol +from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration -from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import __version__ +from homeassistant.const import __version__ as current_version from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.loader import Integration, async_get_custom_components -from .const import CONF_DSN, CONF_ENVIRONMENT, DOMAIN - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - {vol.Required(CONF_DSN): cv.string, CONF_ENVIRONMENT: cv.string} - ) - }, - extra=vol.ALLOW_EXTRA, +from .const import ( + CONF_DSN, + CONF_ENVIRONMENT, + CONF_EVENT_CUSTOM_COMPONENTS, + CONF_EVENT_HANDLED, + CONF_EVENT_THIRD_PARTY_PACKAGES, + CONF_LOGGING_EVENT_LEVEL, + CONF_LOGGING_LEVEL, + CONF_TRACING, + CONF_TRACING_SAMPLE_RATE, + DEFAULT_LOGGING_EVENT_LEVEL, + DEFAULT_LOGGING_LEVEL, + DEFAULT_TRACING_SAMPLE_RATE, + DOMAIN, + ENTITY_COMPONENTS, ) +CONFIG_SCHEMA = cv.deprecated(DOMAIN, invalidation_version="0.117") -async def async_setup(hass: HomeAssistant, config: dict): + +LOGGER_INFO_REGEX = re.compile(r"^(\w+)\.?(\w+)?\.?(\w+)?\.?(\w+)?(?:\..*)?$") + + +async def async_setup(hass: HomeAssistant, config: dict) -> bool: """Set up the Sentry component.""" - conf = config.get(DOMAIN) - if conf is not None: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - ) - return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Sentry from a config entry.""" - conf = entry.data - hass.data[DOMAIN] = conf + # Migrate environment from config entry data to config entry options + if ( + CONF_ENVIRONMENT not in entry.options + and CONF_ENVIRONMENT in entry.data + and entry.data[CONF_ENVIRONMENT] + ): + options = {**entry.options, CONF_ENVIRONMENT: entry.data[CONF_ENVIRONMENT]} + data = entry.data.copy() + data.pop(CONF_ENVIRONMENT) + hass.config_entries.async_update_entry(entry, data=data, options=options) # https://docs.sentry.io/platforms/python/logging/ sentry_logging = LoggingIntegration( - level=logging.INFO, # Capture info and above as breadcrumbs - event_level=logging.ERROR, # Send errors as events + level=entry.options.get(CONF_LOGGING_LEVEL, DEFAULT_LOGGING_LEVEL), + event_level=entry.options.get( + CONF_LOGGING_EVENT_LEVEL, DEFAULT_LOGGING_EVENT_LEVEL + ), ) + # Additional/extra data collection + channel = get_channel(current_version) + huuid = await hass.helpers.instance_id.async_get() + system_info = await hass.helpers.system_info.async_get_system_info() + custom_components = await async_get_custom_components(hass) + + tracing = {} + if entry.options.get(CONF_TRACING): + tracing = { + "traceparent_v2": True, + "traces_sample_rate": entry.options.get( + CONF_TRACING_SAMPLE_RATE, DEFAULT_TRACING_SAMPLE_RATE + ), + } + sentry_sdk.init( - dsn=conf.get(CONF_DSN), - environment=conf.get(CONF_ENVIRONMENT), - integrations=[sentry_logging], - release=f"homeassistant-{__version__}", + dsn=entry.data[CONF_DSN], + environment=entry.options.get(CONF_ENVIRONMENT), + integrations=[sentry_logging, AioHttpIntegration(), SqlalchemyIntegration()], + release=current_version, + before_send=lambda event, hint: process_before_send( + hass, + entry.options, + channel, + huuid, + system_info, + custom_components, + event, + hint, + ), + **tracing, ) return True + + +def get_channel(version: str) -> str: + """Find channel based on version number.""" + if "dev0" in version: + return "dev" + if "dev" in version: + return "nightly" + if "b" in version: + return "beta" + return "stable" + + +def process_before_send( + hass: HomeAssistant, + options, + channel: str, + huuid: str, + system_info: Dict[str, Union[bool, str]], + custom_components: Dict[str, Integration], + event, + hint, +): + """Process a Sentry event before sending it to Sentry.""" + # Filter out handled events by default + if ( + "tags" in event + and event.tags.get("handled", "no") == "yes" + and not options.get(CONF_EVENT_HANDLED) + ): + return None + + # Additional tags to add to the event + additional_tags = { + "channel": channel, + "installation_type": system_info["installation_type"], + "uuid": huuid, + } + + # Find out all integrations in use, filter "auth", because it + # triggers security rules, hiding all data. + integrations = [ + integration + for integration in hass.config.components + if integration != "auth" and "." not in integration + ] + + # Add additional tags based on what caused the event. + platform = entity_platform.current_platform.get() + if platform is not None: + # This event happened in a platform + additional_tags["custom_component"] = "no" + additional_tags["integration"] = platform.platform_name + additional_tags["platform"] = platform.domain + elif "logger" in event: + # Logger event, try to get integration information from the logger name. + matches = LOGGER_INFO_REGEX.findall(event["logger"]) + if matches: + group1, group2, group3, group4 = matches[0] + # Handle the "homeassistant." package differently + if group1 == "homeassistant" and group2 and group3: + if group2 == "components": + # This logger is from a component + additional_tags["custom_component"] = "no" + additional_tags["integration"] = group3 + if group4 and group4 in ENTITY_COMPONENTS: + additional_tags["platform"] = group4 + else: + # Not a component, could be helper, or something else. + additional_tags[group2] = group3 + else: + # Not the "homeassistant" package, this third-party + if not options.get(CONF_EVENT_THIRD_PARTY_PACKAGES): + return None + additional_tags["package"] = group1 + + # If this event is caused by an integration, add a tag if this + # integration is custom or not. + if ( + "integration" in additional_tags + and additional_tags["integration"] in custom_components + ): + if not options.get(CONF_EVENT_CUSTOM_COMPONENTS): + return None + additional_tags["custom_component"] = "yes" + + # Update event with the additional tags + event.setdefault("tags", {}).update(additional_tags) + + # Update event data with Home Assistant Context + event.setdefault("contexts", {}).update( + { + "Home Assistant": { + "channel": channel, + "custom_components": "\n".join(sorted(custom_components)), + "integrations": "\n".join(sorted(integrations)), + **system_info, + }, + } + ) + return event diff --git a/homeassistant/components/sentry/config_flow.py b/homeassistant/components/sentry/config_flow.py index 194aa527d63..a308423f40b 100644 --- a/homeassistant/components/sentry/config_flow.py +++ b/homeassistant/components/sentry/config_flow.py @@ -1,56 +1,137 @@ """Config flow for sentry integration.""" +from __future__ import annotations + import logging +from typing import Any, Dict, Optional from sentry_sdk.utils import BadDsn, Dsn import voluptuous as vol -from homeassistant import config_entries, core +from homeassistant import config_entries +from homeassistant.core import callback -from .const import CONF_DSN, DOMAIN # pylint: disable=unused-import +from .const import ( # pylint: disable=unused-import + CONF_DSN, + CONF_ENVIRONMENT, + CONF_EVENT_CUSTOM_COMPONENTS, + CONF_EVENT_HANDLED, + CONF_EVENT_THIRD_PARTY_PACKAGES, + CONF_LOGGING_EVENT_LEVEL, + CONF_LOGGING_LEVEL, + CONF_TRACING, + CONF_TRACING_SAMPLE_RATE, + DEFAULT_LOGGING_EVENT_LEVEL, + DEFAULT_LOGGING_LEVEL, + DEFAULT_TRACING_SAMPLE_RATE, + DOMAIN, + LOGGING_LEVELS, +) _LOGGER = logging.getLogger(__name__) DATA_SCHEMA = vol.Schema({vol.Required(CONF_DSN): str}) -async def validate_input(hass: core.HomeAssistant, data): - """Validate the DSN input allows us to connect. - - Data has the keys from DATA_SCHEMA with values provided by the user. - """ - # validate the dsn - Dsn(data["dsn"]) - - return {"title": "Sentry"} - - -class DomainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class SentryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a Sentry config flow.""" VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL - async def async_step_user(self, user_input=None): + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> SentryOptionsFlow: + """Get the options flow for this handler.""" + return SentryOptionsFlow(config_entry) + + async def async_step_user( + self, user_input: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: """Handle a user config flow.""" if self._async_current_entries(): - return self.async_abort(reason="already_configured") + return self.async_abort(reason="single_instance_allowed") errors = {} if user_input is not None: try: - info = await validate_input(self.hass, user_input) - - return self.async_create_entry(title=info["title"], data=user_input) + Dsn(user_input["dsn"]) except BadDsn: errors["base"] = "bad_dsn" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" + else: + return self.async_create_entry(title="Sentry", data=user_input) return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_import(self, import_config): - """Import a config entry from configuration.yaml.""" - return await self.async_step_user(import_config) + +class SentryOptionsFlow(config_entries.OptionsFlow): + """Handle Sentry options.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Initialize Sentry options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """Manage Sentry options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_LOGGING_EVENT_LEVEL, + default=self.config_entry.options.get( + CONF_LOGGING_EVENT_LEVEL, DEFAULT_LOGGING_EVENT_LEVEL + ), + ): vol.In(LOGGING_LEVELS), + vol.Optional( + CONF_LOGGING_LEVEL, + default=self.config_entry.options.get( + CONF_LOGGING_LEVEL, DEFAULT_LOGGING_LEVEL + ), + ): vol.In(LOGGING_LEVELS), + vol.Optional( + CONF_ENVIRONMENT, + default=self.config_entry.options.get(CONF_ENVIRONMENT), + ): str, + vol.Optional( + CONF_EVENT_HANDLED, + default=self.config_entry.options.get( + CONF_EVENT_HANDLED, False + ), + ): bool, + vol.Optional( + CONF_EVENT_CUSTOM_COMPONENTS, + default=self.config_entry.options.get( + CONF_EVENT_CUSTOM_COMPONENTS, False + ), + ): bool, + vol.Optional( + CONF_EVENT_THIRD_PARTY_PACKAGES, + default=self.config_entry.options.get( + CONF_EVENT_THIRD_PARTY_PACKAGES, False + ), + ): bool, + vol.Optional( + CONF_TRACING, + default=self.config_entry.options.get(CONF_TRACING, False), + ): bool, + vol.Optional( + CONF_TRACING_SAMPLE_RATE, + default=self.config_entry.options.get( + CONF_TRACING_SAMPLE_RATE, DEFAULT_TRACING_SAMPLE_RATE + ), + ): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=1.0)), + } + ), + ) diff --git a/homeassistant/components/sentry/const.py b/homeassistant/components/sentry/const.py index 1f799eb7fb9..419bbc4e097 100644 --- a/homeassistant/components/sentry/const.py +++ b/homeassistant/components/sentry/const.py @@ -1,6 +1,52 @@ """Constants for the sentry integration.""" +import logging + DOMAIN = "sentry" CONF_DSN = "dsn" CONF_ENVIRONMENT = "environment" +CONF_EVENT_CUSTOM_COMPONENTS = "event_custom_components" +CONF_EVENT_HANDLED = "event_handled" +CONF_EVENT_THIRD_PARTY_PACKAGES = "event_third_party_packages" +CONF_LOGGING_EVENT_LEVEL = "logging_event_level" +CONF_LOGGING_LEVEL = "logging_level" +CONF_TRACING = "tracing" +CONF_TRACING_SAMPLE_RATE = "tracing_sample_rate" + +DEFAULT_LOGGING_EVENT_LEVEL = logging.ERROR +DEFAULT_LOGGING_LEVEL = logging.WARNING +DEFAULT_TRACING_SAMPLE_RATE = 1.0 + +LOGGING_LEVELS = { + logging.DEBUG: "debug", + logging.INFO: "info", + logging.WARNING: "warning", + logging.ERROR: "error", + logging.CRITICAL: "critical", +} + +ENTITY_COMPONENTS = [ + "air_quality", + "alarm_control_panel", + "binary_sensor", + "calendar", + "camera", + "climate", + "cover", + "device_tracker", + "fan", + "geo_location", + "group", + "humidifier", + "light", + "lock", + "media_player", + "remote", + "scene", + "sensor", + "switch", + "vacuum", + "water_heater", + "weather", +] diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 12c808b6d7b..5ed4649b7c4 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -3,6 +3,6 @@ "name": "Sentry", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sentry", - "requirements": ["sentry-sdk==0.13.5"], - "codeowners": ["@dcramer"] + "requirements": ["sentry-sdk==0.16.5"], + "codeowners": ["@dcramer", "@frenck"] } diff --git a/homeassistant/components/sentry/strings.json b/homeassistant/components/sentry/strings.json index 97a945a5a9d..593a8c5c8d0 100644 --- a/homeassistant/components/sentry/strings.json +++ b/homeassistant/components/sentry/strings.json @@ -1,9 +1,31 @@ { "config": { "step": { - "user": { "title": "Sentry", "description": "Enter your Sentry DSN" } + "user": { + "title": "Sentry", + "description": "Enter your Sentry DSN", + "data": { "dsn": "DSN" } + } }, "error": { "unknown": "Unexpected error", "bad_dsn": "Invalid DSN" }, - "abort": { "already_configured": "Sentry is already configured" } + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "environment": "Optional name of the environment.", + "event_custom_components": "Send events from custom components", + "event_handled": "Send handled events", + "event_third_party_packages": "Send events from third-party packages", + "logging_event_level": "The log level Sentry will register an event for", + "logging_level": "The log level Sentry will record logs as breadcrums for", + "tracing": "Enable performance tracing", + "tracing_sample_rate": "Tracing sample rate; between 0.0 and 1.0 (1.0 = 100%)" + } + } + } } } diff --git a/requirements_all.txt b/requirements_all.txt index 7d13febc5a5..356ac9ec5eb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1961,7 +1961,7 @@ sense-hat==2.2.0 sense_energy==0.7.2 # homeassistant.components.sentry -sentry-sdk==0.13.5 +sentry-sdk==0.16.5 # homeassistant.components.aquostv sharp_aquos_rc==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a35bd16a77..520392eb492 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -888,7 +888,7 @@ samsungtvws[websocket]==1.4.0 sense_energy==0.7.2 # homeassistant.components.sentry -sentry-sdk==0.13.5 +sentry-sdk==0.16.5 # homeassistant.components.sighthound simplehound==0.3 diff --git a/tests/components/sentry/test_config_flow.py b/tests/components/sentry/test_config_flow.py index 25353751f91..9f060b58355 100644 --- a/tests/components/sentry/test_config_flow.py +++ b/tests/components/sentry/test_config_flow.py @@ -1,25 +1,37 @@ """Test the sentry config flow.""" +import logging + from sentry_sdk.utils import BadDsn -from homeassistant import config_entries, setup -from homeassistant.components.sentry.const import DOMAIN +from homeassistant.components.sentry.const import ( + CONF_ENVIRONMENT, + CONF_EVENT_CUSTOM_COMPONENTS, + CONF_EVENT_HANDLED, + CONF_EVENT_THIRD_PARTY_PACKAGES, + CONF_LOGGING_EVENT_LEVEL, + CONF_LOGGING_LEVEL, + CONF_TRACING, + CONF_TRACING_SAMPLE_RATE, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM +from homeassistant.setup import async_setup_component from tests.async_mock import patch +from tests.common import MockConfigEntry -async def test_form(hass): +async def test_full_user_flow_implementation(hass): """Test we get the form.""" - await setup.async_setup_component(hass, "persistent_notification", {}) + await async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == RESULT_TYPE_FORM assert result["errors"] == {} - with patch( - "homeassistant.components.sentry.config_flow.validate_input", - return_value={"title": "Sentry"}, - ), patch( + with patch("homeassistant.components.sentry.config_flow.Dsn"), patch( "homeassistant.components.sentry.async_setup", return_value=True ) as mock_setup, patch( "homeassistant.components.sentry.async_setup_entry", return_value=True, @@ -34,23 +46,94 @@ async def test_form(hass): "dsn": "http://public@sentry.local/1", } await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_bad_dsn(hass): +async def test_integration_already_exists(hass): + """Test we only allow a single config flow.""" + MockConfigEntry(domain=DOMAIN).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_user_flow_bad_dsn(hass): """Test we handle bad dsn error.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) with patch( - "homeassistant.components.sentry.config_flow.validate_input", - side_effect=BadDsn, + "homeassistant.components.sentry.config_flow.Dsn", side_effect=BadDsn, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"dsn": "foo"}, ) - assert result2["type"] == "form" + assert result2["type"] == RESULT_TYPE_FORM assert result2["errors"] == {"base": "bad_dsn"} + + +async def test_user_flow_unkown_exception(hass): + """Test we handle any unknown exception error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch( + "homeassistant.components.sentry.config_flow.Dsn", side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"dsn": "foo"}, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_options_flow(hass): + """Test options config flow.""" + entry = MockConfigEntry( + domain=DOMAIN, data={"dsn": "http://public@sentry.local/1"}, + ) + entry.add_to_hass(hass) + + with patch("homeassistant.components.sentry.async_setup_entry", return_value=True): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_ENVIRONMENT: "Test", + CONF_EVENT_CUSTOM_COMPONENTS: True, + CONF_EVENT_HANDLED: True, + CONF_EVENT_THIRD_PARTY_PACKAGES: True, + CONF_LOGGING_EVENT_LEVEL: logging.DEBUG, + CONF_LOGGING_LEVEL: logging.DEBUG, + CONF_TRACING: True, + CONF_TRACING_SAMPLE_RATE: 0.5, + }, + ) + + assert result["type"] == "create_entry" + assert result["data"] == { + CONF_ENVIRONMENT: "Test", + CONF_EVENT_CUSTOM_COMPONENTS: True, + CONF_EVENT_HANDLED: True, + CONF_EVENT_THIRD_PARTY_PACKAGES: True, + CONF_LOGGING_EVENT_LEVEL: logging.DEBUG, + CONF_LOGGING_LEVEL: logging.DEBUG, + CONF_TRACING: True, + CONF_TRACING_SAMPLE_RATE: 0.5, + } diff --git a/tests/components/sentry/test_init.py b/tests/components/sentry/test_init.py new file mode 100644 index 00000000000..5f89b129aca --- /dev/null +++ b/tests/components/sentry/test_init.py @@ -0,0 +1,330 @@ +"""Tests for Sentry integration.""" +import logging + +import pytest + +from homeassistant.components.sentry import get_channel, process_before_send +from homeassistant.components.sentry.const import ( + CONF_DSN, + CONF_ENVIRONMENT, + CONF_EVENT_CUSTOM_COMPONENTS, + CONF_EVENT_HANDLED, + CONF_EVENT_THIRD_PARTY_PACKAGES, + CONF_TRACING, + CONF_TRACING_SAMPLE_RATE, + DOMAIN, +) +from homeassistant.const import __version__ as current_version +from homeassistant.core import HomeAssistant + +from tests.async_mock import MagicMock, Mock, patch +from tests.common import MockConfigEntry + + +async def test_setup_entry(hass: HomeAssistant) -> None: + """Test integration setup from entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_DSN: "http://public@example.com/1", CONF_ENVIRONMENT: "production"}, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.sentry.AioHttpIntegration" + ) as sentry_aiohttp_mock, patch( + "homeassistant.components.sentry.SqlalchemyIntegration" + ) as sentry_sqlalchemy_mock, patch( + "homeassistant.components.sentry.LoggingIntegration" + ) as sentry_logging_mock, patch( + "homeassistant.components.sentry.sentry_sdk" + ) as sentry_mock: + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Test CONF_ENVIRONMENT is migrated to entry options + assert CONF_ENVIRONMENT not in entry.data + assert CONF_ENVIRONMENT in entry.options + assert entry.options[CONF_ENVIRONMENT] == "production" + + assert sentry_logging_mock.call_count == 1 + assert sentry_logging_mock.called_once_with( + level=logging.WARNING, event_level=logging.WARNING + ) + + assert sentry_aiohttp_mock.call_count == 1 + assert sentry_sqlalchemy_mock.call_count == 1 + assert sentry_mock.init.call_count == 1 + + call_args = sentry_mock.init.call_args[1] + assert set(call_args) == { + "dsn", + "environment", + "integrations", + "release", + "before_send", + } + assert call_args["dsn"] == "http://public@example.com/1" + assert call_args["environment"] == "production" + assert call_args["integrations"] == [ + sentry_logging_mock.return_value, + sentry_aiohttp_mock.return_value, + sentry_sqlalchemy_mock.return_value, + ] + assert call_args["release"] == current_version + assert call_args["before_send"] + + +async def test_setup_entry_with_tracing(hass: HomeAssistant) -> None: + """Test integration setup from entry with tracing enabled.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_DSN: "http://public@example.com/1"}, + options={CONF_TRACING: True, CONF_TRACING_SAMPLE_RATE: 0.5}, + ) + entry.add_to_hass(hass) + + with patch("homeassistant.components.sentry.AioHttpIntegration"), patch( + "homeassistant.components.sentry.SqlalchemyIntegration" + ), patch("homeassistant.components.sentry.LoggingIntegration"), patch( + "homeassistant.components.sentry.sentry_sdk" + ) as sentry_mock: + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + call_args = sentry_mock.init.call_args[1] + assert set(call_args) == { + "dsn", + "environment", + "integrations", + "release", + "before_send", + "traceparent_v2", + "traces_sample_rate", + } + assert call_args["traces_sample_rate"] == 0.5 + assert call_args["traceparent_v2"] + + +@pytest.mark.parametrize( + "version,channel", + [ + ("0.115.0.dev20200815", "nightly"), + ("0.115.0", "stable"), + ("0.115.0b4", "beta"), + ("0.115.0dev0", "dev"), + ], +) +async def test_get_channel(version, channel) -> None: + """Test if channel detection works from Home Assistant version number.""" + assert get_channel(version) == channel + + +async def test_process_before_send(hass: HomeAssistant): + """Test regular use of the Sentry process before sending function.""" + hass.config.components.add("puppies") + hass.config.components.add("a_integration") + + # These should not show up in the result. + hass.config.components.add("puppies.light") + hass.config.components.add("auth") + + result = process_before_send( + hass, + options={}, + channel="test", + huuid="12345", + system_info={"installation_type": "pytest"}, + custom_components=["ironing_robot", "fridge_opener"], + event={}, + hint={}, + ) + + assert result + assert result["tags"] + assert result["contexts"] + assert result["contexts"] + + ha_context = result["contexts"]["Home Assistant"] + assert ha_context["channel"] == "test" + assert ha_context["custom_components"] == "fridge_opener\nironing_robot" + assert ha_context["integrations"] == "a_integration\npuppies" + + tags = result["tags"] + assert tags["channel"] == "test" + assert tags["uuid"] == "12345" + assert tags["installation_type"] == "pytest" + + +async def test_event_with_platform_context(hass: HomeAssistant): + """Test extraction of platform context information during Sentry events.""" + + current_platform_mock = Mock() + current_platform_mock.get().platform_name = "hue" + current_platform_mock.get().domain = "light" + + with patch( + "homeassistant.components.sentry.entity_platform.current_platform", + new=current_platform_mock, + ): + result = process_before_send( + hass, + options={}, + channel="test", + huuid="12345", + system_info={"installation_type": "pytest"}, + custom_components=["ironing_robot"], + event={}, + hint={}, + ) + + assert result + assert result["tags"]["integration"] == "hue" + assert result["tags"]["platform"] == "light" + assert result["tags"]["custom_component"] == "no" + + current_platform_mock.get().platform_name = "ironing_robot" + current_platform_mock.get().domain = "switch" + + with patch( + "homeassistant.components.sentry.entity_platform.current_platform", + new=current_platform_mock, + ): + result = process_before_send( + hass, + options={CONF_EVENT_CUSTOM_COMPONENTS: True}, + channel="test", + huuid="12345", + system_info={"installation_type": "pytest"}, + custom_components=["ironing_robot"], + event={}, + hint={}, + ) + + assert result + assert result["tags"]["integration"] == "ironing_robot" + assert result["tags"]["platform"] == "switch" + assert result["tags"]["custom_component"] == "yes" + + +@pytest.mark.parametrize( + "logger,tags", + [ + ("adguard", {"package": "adguard"}), + ( + "homeassistant.components.hue.coordinator", + {"integration": "hue", "custom_component": "no"}, + ), + ( + "homeassistant.components.hue.light", + {"integration": "hue", "platform": "light", "custom_component": "no"}, + ), + ( + "homeassistant.components.ironing_robot.switch", + { + "integration": "ironing_robot", + "platform": "switch", + "custom_component": "yes", + }, + ), + ( + "homeassistant.components.ironing_robot", + {"integration": "ironing_robot", "custom_component": "yes"}, + ), + ("homeassistant.helpers.network", {"helpers": "network"}), + ("tuyapi.test", {"package": "tuyapi"}), + ], +) +async def test_logger_event_extraction(hass: HomeAssistant, logger, tags): + """Test extraction of information from Sentry logger events.""" + + result = process_before_send( + hass, + options={ + CONF_EVENT_CUSTOM_COMPONENTS: True, + CONF_EVENT_THIRD_PARTY_PACKAGES: True, + }, + channel="test", + huuid="12345", + system_info={"installation_type": "pytest"}, + custom_components=["ironing_robot"], + event={"logger": logger}, + hint={}, + ) + + assert result + assert result["tags"] == { + "channel": "test", + "uuid": "12345", + "installation_type": "pytest", + **tags, + } + + +@pytest.mark.parametrize( + "logger,options,event", + [ + ("adguard", {CONF_EVENT_THIRD_PARTY_PACKAGES: True}, True), + ("adguard", {CONF_EVENT_THIRD_PARTY_PACKAGES: False}, False), + ( + "homeassistant.components.ironing_robot.switch", + {CONF_EVENT_CUSTOM_COMPONENTS: True}, + True, + ), + ( + "homeassistant.components.ironing_robot.switch", + {CONF_EVENT_CUSTOM_COMPONENTS: False}, + False, + ), + ], +) +async def test_filter_log_events(hass: HomeAssistant, logger, options, event): + """Test filtering of events based on configuration options.""" + result = process_before_send( + hass, + options=options, + channel="test", + huuid="12345", + system_info={"installation_type": "pytest"}, + custom_components=["ironing_robot"], + event={"logger": logger}, + hint={}, + ) + + if event: + assert result + else: + assert result is None + + +@pytest.mark.parametrize( + "handled,options,event", + [ + ("yes", {CONF_EVENT_HANDLED: True}, True), + ("yes", {CONF_EVENT_HANDLED: False}, False), + ("no", {CONF_EVENT_HANDLED: False}, True), + ("no", {CONF_EVENT_HANDLED: True}, True), + ], +) +async def test_filter_handled_events(hass: HomeAssistant, handled, options, event): + """Tests filtering of handled events based on configuration options.""" + + event_mock = MagicMock() + event_mock.__iter__ = ["tags"] + event_mock.__contains__ = lambda _, val: val == "tags" + event_mock.tags = {"handled": handled} + + result = process_before_send( + hass, + options=options, + channel="test", + huuid="12345", + system_info={"installation_type": "pytest"}, + custom_components=[], + event=event_mock, + hint={}, + ) + + if event: + assert result + else: + assert result is None