diff --git a/.github/workflows/test-integrations-ai.yml b/.github/workflows/test-integrations-ai.yml index f82f317fbe..99f59df833 100644 --- a/.github/workflows/test-integrations-ai.yml +++ b/.github/workflows/test-integrations-ai.yml @@ -70,7 +70,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -132,7 +132,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-cloud.yml b/.github/workflows/test-integrations-cloud.yml index 341e531e31..c87e85dd85 100644 --- a/.github/workflows/test-integrations-cloud.yml +++ b/.github/workflows/test-integrations-cloud.yml @@ -74,7 +74,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -140,7 +140,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-common.yml b/.github/workflows/test-integrations-common.yml index 59524f2d93..3ff2d2c957 100644 --- a/.github/workflows/test-integrations-common.yml +++ b/.github/workflows/test-integrations-common.yml @@ -54,7 +54,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-dbs.yml b/.github/workflows/test-integrations-dbs.yml index 5d842bb403..a930f00600 100644 --- a/.github/workflows/test-integrations-dbs.yml +++ b/.github/workflows/test-integrations-dbs.yml @@ -94,7 +94,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -180,7 +180,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-flags.yml b/.github/workflows/test-integrations-flags.yml index ffcef6e799..46be48f651 100644 --- a/.github/workflows/test-integrations-flags.yml +++ b/.github/workflows/test-integrations-flags.yml @@ -66,7 +66,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-gevent.yml b/.github/workflows/test-integrations-gevent.yml index 76c70d8ac7..30858d1cb2 100644 --- a/.github/workflows/test-integrations-gevent.yml +++ b/.github/workflows/test-integrations-gevent.yml @@ -54,7 +54,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-graphql.yml b/.github/workflows/test-integrations-graphql.yml index 02ccf1804c..e0384757a2 100644 --- a/.github/workflows/test-integrations-graphql.yml +++ b/.github/workflows/test-integrations-graphql.yml @@ -66,7 +66,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-misc.yml b/.github/workflows/test-integrations-misc.yml index 2c8b4044d5..3d5bd4b54a 100644 --- a/.github/workflows/test-integrations-misc.yml +++ b/.github/workflows/test-integrations-misc.yml @@ -74,7 +74,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-network.yml b/.github/workflows/test-integrations-network.yml index f7c2dc5ed7..dad7f2319c 100644 --- a/.github/workflows/test-integrations-network.yml +++ b/.github/workflows/test-integrations-network.yml @@ -62,7 +62,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -116,7 +116,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-tasks.yml b/.github/workflows/test-integrations-tasks.yml index 6d4fdfeb6b..29c7aaf40b 100644 --- a/.github/workflows/test-integrations-tasks.yml +++ b/.github/workflows/test-integrations-tasks.yml @@ -84,7 +84,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -160,7 +160,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-web-1.yml b/.github/workflows/test-integrations-web-1.yml index 14188fb047..c7921fcdfe 100644 --- a/.github/workflows/test-integrations-web-1.yml +++ b/.github/workflows/test-integrations-web-1.yml @@ -84,7 +84,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-web-2.yml b/.github/workflows/test-integrations-web-2.yml index 624b46bf9a..8fc63838b5 100644 --- a/.github/workflows/test-integrations-web-2.yml +++ b/.github/workflows/test-integrations-web-2.yml @@ -90,7 +90,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -172,7 +172,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@v5.4.3 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c9fb9ae0c..f2eb42d0e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,43 @@ for your feedback. How was the migration? Is everything working as expected? Is [on GitHub](https://github.com/getsentry/sentry-python/discussions/3936) or [on Discord](https://discord.gg/wdNEHETs87). +## 2.29.1 + +### Various fixes & improvements + +- fix(logs): send `severity_text`: `warn` instead of `warning` (#4396) by @lcian + +## 2.29.0 + +### Various fixes & improvements + +- fix(loguru): Move integration setup from `__init__` to `setup_once` (#4399) by @sentrivana +- feat: Allow configuring `keep_alive` via environment variable (#4366) by @szokeasaurusrex +- fix(celery): Do not send extra check-in (#4395) by @sentrivana +- fix(typing): Add before_send_log to Experiments (#4383) by @sentrivana +- ci: Fix pyspark test suite (#4382) by @sentrivana +- fix(logs): Make `sentry.message.parameters` singular as per spec (#4387) by @AbhiPrasad +- apidocs: Remove snowballstemmer pin (#4379) by @sentrivana + +## 2.28.0 + +### Various fixes & improvements + +- fix(logs): Forward `extra` from logger as attributes (#4374) by @AbhiPrasad +- fix(logs): Canonicalize paths from the logger integration (#4336) by @colin-sentry +- fix(logs): Use new transport (#4317) by @colin-sentry +- fix: Deprecate `set_measurement()` API. (#3934) by @antonpirker +- fix: Put feature flags on isolation scope (#4363) by @antonpirker +- fix: Make use of `SPANDATA` consistent (#4373) by @antonpirker +- fix: Discord link (#4371) by @sentrivana +- tests: Pin snowballstemmer for now (#4372) by @sentrivana +- tests: Regular tox update (#4367) by @sentrivana +- tests: Bump test timeout for recursion stacktrace extract to 2s (#4351) by @booxter +- tests: Fix test_stacktrace_big_recursion failure due to argv (#4346) by @booxter +- tests: Move anthropic under toxgen (#4348) by @sentrivana +- tests: Update tox.ini (#4347) by @sentrivana +- chore: Update GH issue templates for Linear compatibility (#4328) by @stephanie-anderson +- chore: Bump actions/create-github-app-token from 2.0.2 to 2.0.6 (#4358) by @dependabot ## 2.27.0 diff --git a/requirements-docs.txt b/requirements-docs.txt index a662a0d83f..81e04ba3ef 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -3,4 +3,3 @@ shibuya sphinx<8.2 sphinx-autodoc-typehints[type_comments]>=1.8.0 typing-extensions -snowballstemmer<3.0 diff --git a/scripts/split_tox_gh_actions/templates/test_group.jinja b/scripts/split_tox_gh_actions/templates/test_group.jinja index cd2b45805b..a7f8be9f9a 100644 --- a/scripts/split_tox_gh_actions/templates/test_group.jinja +++ b/scripts/split_tox_gh_actions/templates/test_group.jinja @@ -77,7 +77,7 @@ - name: Upload coverage to Codecov if: {% raw %}${{ !cancelled() }}{% endraw %} - uses: codecov/codecov-action@v5.4.2 + uses: codecov/codecov-action@v5.4.3 with: token: {% raw %}${{ secrets.CODECOV_TOKEN }}{% endraw %} files: coverage.xml diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 2f4349253b..29b10c9300 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -133,6 +133,11 @@ def _get_options(*args, **kwargs): ) rv["socket_options"] = None + if rv["keep_alive"] is None: + rv["keep_alive"] = ( + env_to_bool(os.environ.get("SENTRY_KEEP_ALIVE"), strict=True) or False + ) + return rv diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index f4d877b0bc..8d06bad2a1 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -1,5 +1,4 @@ import itertools - from enum import Enum from typing import TYPE_CHECKING @@ -47,6 +46,7 @@ class CompressionAlgo(Enum): Event, EventProcessor, Hint, + Log, ProfilerMode, TracesSampler, TransactionProcessor, @@ -71,6 +71,7 @@ class CompressionAlgo(Enum): "transport_num_pools": Optional[int], "transport_http2": Optional[bool], "enable_logs": Optional[bool], + "before_send_log": Optional[Callable[[Log, Hint], Optional[Log]]], }, total=False, ) @@ -622,7 +623,7 @@ def __init__( ignore_errors=[], # type: Sequence[Union[type, str]] # noqa: B006 max_request_body_size="medium", # type: str socket_options=None, # type: Optional[List[Tuple[int, int, int | bytes]]] - keep_alive=False, # type: bool + keep_alive=None, # type: Optional[bool] before_send=None, # type: Optional[EventProcessor] before_breadcrumb=None, # type: Optional[BreadcrumbProcessor] debug=None, # type: Optional[bool] diff --git a/sentry_sdk/integrations/celery/__init__.py b/sentry_sdk/integrations/celery/__init__.py index 95a09e6029..f8cde88682 100644 --- a/sentry_sdk/integrations/celery/__init__.py +++ b/sentry_sdk/integrations/celery/__init__.py @@ -8,7 +8,7 @@ from sentry_sdk.integrations import _check_minimum_version, Integration, DidNotEnable from sentry_sdk.integrations.celery.beat import ( _patch_beat_apply_entry, - _patch_redbeat_maybe_due, + _patch_redbeat_apply_async, _setup_celery_beat_signals, ) from sentry_sdk.integrations.celery.utils import _now_seconds_since_epoch @@ -72,7 +72,7 @@ def __init__( self.exclude_beat_tasks = exclude_beat_tasks _patch_beat_apply_entry() - _patch_redbeat_maybe_due() + _patch_redbeat_apply_async() _setup_celery_beat_signals(monitor_beat_tasks) @staticmethod diff --git a/sentry_sdk/integrations/celery/beat.py b/sentry_sdk/integrations/celery/beat.py index ddbc8561a4..4b7e45e6f0 100644 --- a/sentry_sdk/integrations/celery/beat.py +++ b/sentry_sdk/integrations/celery/beat.py @@ -202,12 +202,12 @@ def _patch_beat_apply_entry(): Scheduler.apply_entry = _wrap_beat_scheduler(Scheduler.apply_entry) -def _patch_redbeat_maybe_due(): +def _patch_redbeat_apply_async(): # type: () -> None if RedBeatScheduler is None: return - RedBeatScheduler.maybe_due = _wrap_beat_scheduler(RedBeatScheduler.maybe_due) + RedBeatScheduler.apply_async = _wrap_beat_scheduler(RedBeatScheduler.apply_async) def _setup_celery_beat_signals(monitor_beat_tasks): diff --git a/sentry_sdk/integrations/logging.py b/sentry_sdk/integrations/logging.py index 477139be3a..cb1c67270e 100644 --- a/sentry_sdk/integrations/logging.py +++ b/sentry_sdk/integrations/logging.py @@ -353,23 +353,21 @@ def emit(self, record): if not client.options["_experiments"].get("enable_logs", False): return - SentryLogsHandler._capture_log_from_record(client, record) + self._capture_log_from_record(client, record) - @staticmethod - def _capture_log_from_record(client, record): + def _capture_log_from_record(self, client, record): # type: (BaseClient, LogRecord) -> None scope = sentry_sdk.get_current_scope() otel_severity_number, otel_severity_text = _python_level_to_otel(record.levelno) project_root = client.options["project_root"] - attrs = { - "sentry.origin": "auto.logger.log", - } # type: dict[str, str | bool | float | int] + attrs = self._extra_from_record(record) # type: Any + attrs["sentry.origin"] = "auto.logger.log" if isinstance(record.msg, str): attrs["sentry.message.template"] = record.msg if record.args is not None: if isinstance(record.args, tuple): for i, arg in enumerate(record.args): - attrs[f"sentry.message.parameters.{i}"] = ( + attrs[f"sentry.message.parameter.{i}"] = ( arg if isinstance(arg, str) or isinstance(arg, float) diff --git a/sentry_sdk/integrations/loguru.py b/sentry_sdk/integrations/loguru.py index 5b76ea812a..a71c4ac87f 100644 --- a/sentry_sdk/integrations/loguru.py +++ b/sentry_sdk/integrations/loguru.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from logging import LogRecord - from typing import Optional, Tuple, Any + from typing import Optional, Any try: import loguru @@ -43,16 +43,16 @@ class LoggingLevels(enum.IntEnum): DEFAULT_LEVEL = LoggingLevels.INFO.value DEFAULT_EVENT_LEVEL = LoggingLevels.ERROR.value -# We need to save the handlers to be able to remove them later -# in tests (they call `LoguruIntegration.__init__` multiple times, -# and we can't use `setup_once` because it's called before -# than we get configuration). -_ADDED_HANDLERS = (None, None) # type: Tuple[Optional[int], Optional[int]] class LoguruIntegration(Integration): identifier = "loguru" + level = DEFAULT_LEVEL # type: Optional[int] + event_level = DEFAULT_EVENT_LEVEL # type: Optional[int] + breadcrumb_format = DEFAULT_FORMAT + event_format = DEFAULT_FORMAT + def __init__( self, level=DEFAULT_LEVEL, @@ -61,36 +61,27 @@ def __init__( event_format=DEFAULT_FORMAT, ): # type: (Optional[int], Optional[int], str | loguru.FormatFunction, str | loguru.FormatFunction) -> None - global _ADDED_HANDLERS - breadcrumb_handler, event_handler = _ADDED_HANDLERS - - if breadcrumb_handler is not None: - logger.remove(breadcrumb_handler) - breadcrumb_handler = None - if event_handler is not None: - logger.remove(event_handler) - event_handler = None - - if level is not None: - breadcrumb_handler = logger.add( - LoguruBreadcrumbHandler(level=level), - level=level, - format=breadcrumb_format, - ) - - if event_level is not None: - event_handler = logger.add( - LoguruEventHandler(level=event_level), - level=event_level, - format=event_format, - ) - - _ADDED_HANDLERS = (breadcrumb_handler, event_handler) + LoguruIntegration.level = level + LoguruIntegration.event_level = event_level + LoguruIntegration.breadcrumb_format = breadcrumb_format + LoguruIntegration.event_format = event_format @staticmethod def setup_once(): # type: () -> None - pass # we do everything in __init__ + if LoguruIntegration.level is not None: + logger.add( + LoguruBreadcrumbHandler(level=LoguruIntegration.level), + level=LoguruIntegration.level, + format=LoguruIntegration.breadcrumb_format, + ) + + if LoguruIntegration.event_level is not None: + logger.add( + LoguruEventHandler(level=LoguruIntegration.event_level), + level=LoguruIntegration.event_level, + format=LoguruIntegration.event_format, + ) class _LoguruBaseHandler(_BaseHandler): diff --git a/sentry_sdk/logger.py b/sentry_sdk/logger.py index 1fa31b786b..c675c4d95d 100644 --- a/sentry_sdk/logger.py +++ b/sentry_sdk/logger.py @@ -18,7 +18,7 @@ def _capture_log(severity_text, severity_number, template, **kwargs): if "attributes" in kwargs: attrs.update(kwargs.pop("attributes")) for k, v in kwargs.items(): - attrs[f"sentry.message.parameters.{k}"] = v + attrs[f"sentry.message.parameter.{k}"] = v attrs = { k: ( @@ -51,6 +51,6 @@ def _capture_log(severity_text, severity_number, template, **kwargs): trace = functools.partial(_capture_log, "trace", 1) debug = functools.partial(_capture_log, "debug", 5) info = functools.partial(_capture_log, "info", 9) -warning = functools.partial(_capture_log, "warning", 13) +warning = functools.partial(_capture_log, "warn", 13) error = functools.partial(_capture_log, "error", 17) fatal = functools.partial(_capture_log, "fatal", 21) diff --git a/tests/integrations/celery/test_celery_beat_crons.py b/tests/integrations/celery/test_celery_beat_crons.py index 58c4c6208d..17b4a5e73d 100644 --- a/tests/integrations/celery/test_celery_beat_crons.py +++ b/tests/integrations/celery/test_celery_beat_crons.py @@ -10,7 +10,7 @@ _get_headers, _get_monitor_config, _patch_beat_apply_entry, - _patch_redbeat_maybe_due, + _patch_redbeat_apply_async, crons_task_failure, crons_task_retry, crons_task_success, @@ -454,10 +454,10 @@ def test_exclude_redbeat_tasks_option( """ Test excluding Celery RedBeat tasks from automatic instrumentation. """ - fake_maybe_due = MagicMock() + fake_apply_async = MagicMock() fake_redbeat_scheduler = MagicMock() - fake_redbeat_scheduler.maybe_due = fake_maybe_due + fake_redbeat_scheduler.apply_async = fake_apply_async fake_integration = MagicMock() fake_integration.exclude_beat_tasks = exclude_beat_tasks @@ -481,17 +481,19 @@ def test_exclude_redbeat_tasks_option( "sentry_sdk.integrations.celery.beat._get_monitor_config", fake_get_monitor_config, ) as _get_monitor_config: - # Mimic CeleryIntegration patching of RedBeatScheduler.maybe_due() - _patch_redbeat_maybe_due() + # Mimic CeleryIntegration patching of RedBeatScheduler.apply_async() + _patch_redbeat_apply_async() # Mimic Celery RedBeat calling a task from the RedBeat schedule - RedBeatScheduler.maybe_due(fake_redbeat_scheduler, fake_schedule_entry) + RedBeatScheduler.apply_async( + fake_redbeat_scheduler, fake_schedule_entry + ) if task_in_excluded_beat_tasks: # Only the original RedBeatScheduler.maybe_due() is called, _get_monitor_config is NOT called. - assert fake_maybe_due.call_count == 1 + assert fake_apply_async.call_count == 1 _get_monitor_config.assert_not_called() else: # The original RedBeatScheduler.maybe_due() is called, AND _get_monitor_config is called. - assert fake_maybe_due.call_count == 1 + assert fake_apply_async.call_count == 1 assert _get_monitor_config.call_count == 1 diff --git a/tests/integrations/loguru/test_loguru.py b/tests/integrations/loguru/test_loguru.py index 64e9f22ba5..6be09b86dc 100644 --- a/tests/integrations/loguru/test_loguru.py +++ b/tests/integrations/loguru/test_loguru.py @@ -32,7 +32,12 @@ def test_just_log( expected_sentry_level, disable_breadcrumbs, disable_events, + uninstall_integration, + request, ): + uninstall_integration("loguru") + request.addfinalizer(logger.remove) + sentry_init( integrations=[ LoguruIntegration( @@ -49,7 +54,7 @@ def test_just_log( formatted_message = ( " | " + "{:9}".format(level.name.upper()) - + "| tests.integrations.loguru.test_loguru:test_just_log:47 - test" + + "| tests.integrations.loguru.test_loguru:test_just_log:52 - test" ) if not created_event: @@ -78,7 +83,10 @@ def test_just_log( assert event["logentry"]["message"][23:] == formatted_message -def test_breadcrumb_format(sentry_init, capture_events): +def test_breadcrumb_format(sentry_init, capture_events, uninstall_integration, request): + uninstall_integration("loguru") + request.addfinalizer(logger.remove) + sentry_init( integrations=[ LoguruIntegration( @@ -98,7 +106,10 @@ def test_breadcrumb_format(sentry_init, capture_events): assert breadcrumb["message"] == formatted_message -def test_event_format(sentry_init, capture_events): +def test_event_format(sentry_init, capture_events, uninstall_integration, request): + uninstall_integration("loguru") + request.addfinalizer(logger.remove) + sentry_init( integrations=[ LoguruIntegration( diff --git a/tests/integrations/spark/test_spark.py b/tests/integrations/spark/test_spark.py index 7eeab15dc4..91882a0b8f 100644 --- a/tests/integrations/spark/test_spark.py +++ b/tests/integrations/spark/test_spark.py @@ -10,7 +10,7 @@ ) from sentry_sdk.integrations.spark.spark_worker import SparkWorkerIntegration -from pyspark import SparkContext +from pyspark import SparkConf, SparkContext from py4j.protocol import Py4JJavaError @@ -25,12 +25,13 @@ def sentry_init_with_reset(sentry_init): from sentry_sdk.integrations import _processed_integrations yield lambda: sentry_init(integrations=[SparkIntegration()]) - _processed_integrations.remove("spark") + _processed_integrations.discard("spark") @pytest.fixture(scope="function") def create_spark_context(): - yield lambda: SparkContext(appName="Testing123") + conf = SparkConf().set("spark.driver.bindAddress", "127.0.0.1") + yield lambda: SparkContext(conf=conf, appName="Testing123") SparkContext._active_spark_context.stop() diff --git a/tests/test_basics.py b/tests/test_basics.py index f46d2a15ce..f728eafdbf 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -414,6 +414,66 @@ def test_attachments_graceful_failure( assert envelope.items[1].payload.get_bytes() == b"" +def test_attachments_exceptions(sentry_init): + sentry_init() + + scope = sentry_sdk.get_isolation_scope() + + # bytes and path are None + with pytest.raises(TypeError) as e: + scope.add_attachment() + + assert str(e.value) == "path or raw bytes required for attachment" + + # filename is None + with pytest.raises(TypeError) as e: + scope.add_attachment(bytes=b"Hello World!") + + assert str(e.value) == "filename is required for attachment" + + +def test_attachments_content_type_is_none(sentry_init, capture_envelopes): + sentry_init() + envelopes = capture_envelopes() + + scope = sentry_sdk.get_isolation_scope() + + scope.add_attachment( + bytes=b"Hello World!", filename="message.txt", content_type="foo/bar" + ) + capture_exception(ValueError()) + + (envelope,) = envelopes + attachments = [x for x in envelope.items if x.type == "attachment"] + (message,) = attachments + + assert message.headers["filename"] == "message.txt" + assert message.headers["content_type"] == "foo/bar" + + +def test_attachments_repr(sentry_init): + sentry_init() + + scope = sentry_sdk.get_isolation_scope() + + scope.add_attachment(bytes=b"Hello World!", filename="message.txt") + + assert repr(scope._attachments[0]) == "" + + +def test_attachments_bytes_callable_payload(sentry_init): + sentry_init() + + scope = sentry_sdk.get_isolation_scope() + + scope.add_attachment(bytes=bytes, filename="message.txt") + + attachment = scope._attachments[0] + item = attachment.to_envelope_item() + + assert item.payload.bytes == b"" + + def test_integration_scoping(sentry_init, capture_events): logger = logging.getLogger("test_basics") diff --git a/tests/test_client.py b/tests/test_client.py index 9b0b4c3bdb..301c152f85 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,3 +1,4 @@ +import contextlib import os import json import subprocess @@ -1433,3 +1434,66 @@ def run(self, sentry_init, capture_record_lost_event_calls): ) def test_dropped_transaction(sentry_init, capture_record_lost_event_calls, test_config): test_config.run(sentry_init, capture_record_lost_event_calls) + + +def make_options_transport_cls(): + """Make an options transport class that captures the options passed to it.""" + # We need a unique class for each test so that the options are not + # shared between tests. + + class OptionsTransport(Transport): + """Transport that captures the options passed to it.""" + + def __init__(self, options): + super().__init__(options) + type(self).options = options + + def capture_envelope(self, _): + pass + + return OptionsTransport + + +@contextlib.contextmanager +def clear_env_var(name): + """Helper to clear the a given environment variable, + and restore it to its original value on exit.""" + old_value = os.environ.pop(name, None) + + try: + yield + finally: + if old_value is not None: + os.environ[name] = old_value + elif name in os.environ: + del os.environ[name] + + +@pytest.mark.parametrize( + ("env_value", "arg_value", "expected_value"), + [ + (None, None, False), # default + ("0", None, False), # env var false + ("1", None, True), # env var true + (None, False, False), # arg false + (None, True, True), # arg true + # Argument overrides environment variable + ("0", True, True), # env false, arg true + ("1", False, False), # env true, arg false + ], +) +def test_keep_alive(env_value, arg_value, expected_value): + transport_cls = make_options_transport_cls() + keep_alive_kwarg = {} if arg_value is None else {"keep_alive": arg_value} + + with clear_env_var("SENTRY_KEEP_ALIVE"): + if env_value is not None: + os.environ["SENTRY_KEEP_ALIVE"] = env_value + + sentry_sdk.init( + dsn="http://foo@sentry.io/123", + transport=transport_cls, + **keep_alive_kwarg, + ) + + assert transport_cls.options["keep_alive"] is expected_value diff --git a/tests/test_logs.py b/tests/test_logs.py index dfe2284fa6..2aa5383e72 100644 --- a/tests/test_logs.py +++ b/tests/test_logs.py @@ -30,7 +30,7 @@ def _convert_attr(attr): return attr["value"] if attr["value"].startswith("{"): try: - return json.loads(attr["stringValue"]) + return json.loads(attr["value"]) except ValueError: pass return str(attr["value"]) @@ -102,7 +102,7 @@ def test_logs_basics(sentry_init, capture_envelopes): assert logs[2].get("severity_text") == "info" assert logs[2].get("severity_number") == 9 - assert logs[3].get("severity_text") == "warning" + assert logs[3].get("severity_text") == "warn" assert logs[3].get("severity_number") == 13 assert logs[4].get("severity_text") == "error" @@ -155,7 +155,7 @@ def _before_log(record, hint): assert logs[0]["severity_text"] == "trace" assert logs[1]["severity_text"] == "debug" assert logs[2]["severity_text"] == "info" - assert logs[3]["severity_text"] == "warning" + assert logs[3]["severity_text"] == "warn" assert before_log_called[0] @@ -186,7 +186,7 @@ def test_logs_attributes(sentry_init, capture_envelopes): assert logs[0]["attributes"][k] == v assert logs[0]["attributes"]["sentry.environment"] == "production" assert "sentry.release" in logs[0]["attributes"] - assert logs[0]["attributes"]["sentry.message.parameters.my_var"] == "some value" + assert logs[0]["attributes"]["sentry.message.parameter.my_var"] == "some value" assert logs[0]["attributes"][SPANDATA.SERVER_ADDRESS] == "test-server" assert logs[0]["attributes"]["sentry.sdk.name"].startswith("sentry.python") assert logs[0]["attributes"]["sentry.sdk.version"] == VERSION @@ -214,23 +214,23 @@ def test_logs_message_params(sentry_init, capture_envelopes): logs = envelopes_to_logs(envelopes) assert logs[0]["body"] == "The recorded value was '1'" - assert logs[0]["attributes"]["sentry.message.parameters.int_var"] == 1 + assert logs[0]["attributes"]["sentry.message.parameter.int_var"] == 1 assert logs[1]["body"] == "The recorded value was '2.0'" - assert logs[1]["attributes"]["sentry.message.parameters.float_var"] == 2.0 + assert logs[1]["attributes"]["sentry.message.parameter.float_var"] == 2.0 assert logs[2]["body"] == "The recorded value was 'False'" - assert logs[2]["attributes"]["sentry.message.parameters.bool_var"] is False + assert logs[2]["attributes"]["sentry.message.parameter.bool_var"] is False assert logs[3]["body"] == "The recorded value was 'some string value'" assert ( - logs[3]["attributes"]["sentry.message.parameters.string_var"] + logs[3]["attributes"]["sentry.message.parameter.string_var"] == "some string value" ) assert logs[4]["body"] == "The recorded error was 'some error'" assert ( - logs[4]["attributes"]["sentry.message.parameters.error"] + logs[4]["attributes"]["sentry.message.parameter.error"] == "Exception('some error')" ) @@ -287,8 +287,8 @@ def test_logger_integration_warning(sentry_init, capture_envelopes): assert "code.line.number" in attrs assert attrs["logger.name"] == "test-logger" assert attrs["sentry.environment"] == "production" - assert attrs["sentry.message.parameters.0"] == "1" - assert attrs["sentry.message.parameters.1"] == "2" + assert attrs["sentry.message.parameter.0"] == "1" + assert attrs["sentry.message.parameter.1"] == "2" assert attrs["sentry.origin"] == "auto.logger.log" assert logs[0]["severity_number"] == 13 assert logs[0]["severity_text"] == "warn" @@ -352,14 +352,13 @@ def test_logging_errors(sentry_init, capture_envelopes): logs = envelopes_to_logs(envelopes) assert logs[0]["severity_text"] == "error" assert "sentry.message.template" not in logs[0]["attributes"] - assert "sentry.message.parameters.0" not in logs[0]["attributes"] + assert "sentry.message.parameter.0" not in logs[0]["attributes"] assert "code.line.number" in logs[0]["attributes"] assert logs[1]["severity_text"] == "error" assert logs[1]["attributes"]["sentry.message.template"] == "error is %s" assert ( - logs[1]["attributes"]["sentry.message.parameters.0"] - == "Exception('test exc 2')" + logs[1]["attributes"]["sentry.message.parameter.0"] == "Exception('test exc 2')" ) assert "code.line.number" in logs[1]["attributes"] @@ -396,6 +395,79 @@ def test_log_strips_project_root(sentry_init, capture_envelopes): assert attrs["code.file.path"] == "blah/path.py" +def test_logger_with_all_attributes(sentry_init, capture_envelopes): + """ + The python logger should be able to log all attributes, including extra data. + """ + sentry_init(_experiments={"enable_logs": True}) + envelopes = capture_envelopes() + + python_logger = logging.Logger("test-logger") + python_logger.warning( + "log #%d", + 1, + extra={"foo": "bar", "numeric": 42, "more_complex": {"nested": "data"}}, + ) + get_client().flush() + + logs = envelopes_to_logs(envelopes) + + attributes = logs[0]["attributes"] + + assert "process.pid" in attributes + assert isinstance(attributes["process.pid"], int) + del attributes["process.pid"] + + assert "sentry.release" in attributes + assert isinstance(attributes["sentry.release"], str) + del attributes["sentry.release"] + + assert "server.address" in attributes + assert isinstance(attributes["server.address"], str) + del attributes["server.address"] + + assert "thread.id" in attributes + assert isinstance(attributes["thread.id"], int) + del attributes["thread.id"] + + assert "code.file.path" in attributes + assert isinstance(attributes["code.file.path"], str) + del attributes["code.file.path"] + + assert "code.function.name" in attributes + assert isinstance(attributes["code.function.name"], str) + del attributes["code.function.name"] + + assert "code.line.number" in attributes + assert isinstance(attributes["code.line.number"], int) + del attributes["code.line.number"] + + assert "process.executable.name" in attributes + assert isinstance(attributes["process.executable.name"], str) + del attributes["process.executable.name"] + + assert "thread.name" in attributes + assert isinstance(attributes["thread.name"], str) + del attributes["thread.name"] + + assert attributes.pop("sentry.sdk.name").startswith("sentry.python") + + # Assert on the remaining non-dynamic attributes. + assert attributes == { + "foo": "bar", + "numeric": 42, + "more_complex": "{'nested': 'data'}", + "logger.name": "test-logger", + "sentry.origin": "auto.logger.log", + "sentry.message.template": "log #%d", + "sentry.message.parameter.0": 1, + "sentry.environment": "production", + "sentry.sdk.version": VERSION, + "sentry.severity_number": 13, + "sentry.severity_text": "warn", + } + + def test_auto_flush_logs_after_100(sentry_init, capture_envelopes): """ If you log >100 logs, it should automatically trigger a flush. diff --git a/tests/test_utils.py b/tests/test_utils.py index 3ac826141b..e5bad4fa72 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -33,6 +33,10 @@ _generate_installed_modules, ensure_integration_enabled, _serialize_span_attribute, + to_string, + exc_info_from_error, + get_lines_from_file, + package_version, ) @@ -62,6 +66,58 @@ def _normalize_distribution_name(name): return re.sub(r"[-_.]+", "-", name).lower() +isoformat_inputs_and_datetime_outputs = ( + ( + "2021-01-01T00:00:00.000000Z", + datetime(2021, 1, 1, tzinfo=timezone.utc), + ), # UTC time + ( + "2021-01-01T00:00:00.000000", + datetime(2021, 1, 1).astimezone(timezone.utc), + ), # No TZ -- assume local but convert to UTC + ( + "2021-01-01T00:00:00Z", + datetime(2021, 1, 1, tzinfo=timezone.utc), + ), # UTC - No milliseconds + ( + "2021-01-01T00:00:00.000000+00:00", + datetime(2021, 1, 1, tzinfo=timezone.utc), + ), + ( + "2021-01-01T00:00:00.000000-00:00", + datetime(2021, 1, 1, tzinfo=timezone.utc), + ), + ( + "2021-01-01T00:00:00.000000+0000", + datetime(2021, 1, 1, tzinfo=timezone.utc), + ), + ( + "2021-01-01T00:00:00.000000-0000", + datetime(2021, 1, 1, tzinfo=timezone.utc), + ), + ( + "2020-12-31T00:00:00.000000+02:00", + datetime(2020, 12, 31, tzinfo=timezone(timedelta(hours=2))), + ), # UTC+2 time + ( + "2020-12-31T00:00:00.000000-0200", + datetime(2020, 12, 31, tzinfo=timezone(timedelta(hours=-2))), + ), # UTC-2 time + ( + "2020-12-31T00:00:00-0200", + datetime(2020, 12, 31, tzinfo=timezone(timedelta(hours=-2))), + ), # UTC-2 time - no milliseconds +) + + +@pytest.mark.parametrize( + ("input_str", "expected_output"), + isoformat_inputs_and_datetime_outputs, +) +def test_datetime_from_isoformat(input_str, expected_output): + assert datetime_from_isoformat(input_str) == expected_output, input_str + + @pytest.mark.parametrize( "env_var_value,strict,expected", [ @@ -298,6 +354,12 @@ def test_sanitize_url_and_split(url, expected_result): assert sanitized_url.fragment == expected_result.fragment +def test_sanitize_url_remove_authority_is_false(): + url = "https://usr:pwd@example.com" + sanitized_url = sanitize_url(url, remove_authority=False) + assert sanitized_url == url + + @pytest.mark.parametrize( ("url", "sanitize", "expected_url", "expected_query", "expected_fragment"), [ @@ -581,6 +643,17 @@ def test_get_error_message(error, expected_result): assert get_error_message(exc_value) == expected_result(exc_value) +def test_safe_str_fails(): + class ExplodingStr: + def __str__(self): + raise Exception + + obj = ExplodingStr() + result = safe_str(obj) + + assert result == repr(obj) + + def test_installed_modules(): try: from importlib.metadata import distributions, version @@ -664,6 +737,20 @@ def test_default_release_empty_string(): assert release is None +def test_get_default_release_sentry_release_env(monkeypatch): + monkeypatch.setenv("SENTRY_RELEASE", "sentry-env-release") + assert get_default_release() == "sentry-env-release" + + +def test_get_default_release_other_release_env(monkeypatch): + monkeypatch.setenv("SOURCE_VERSION", "other-env-release") + + with mock.patch("sentry_sdk.utils.get_git_revision", return_value=""): + release = get_default_release() + + assert release == "other-env-release" + + def test_ensure_integration_enabled_integration_enabled(sentry_init): def original_function(): return "original" @@ -938,55 +1025,6 @@ def test_serialize_span_attribute(value, result): assert _serialize_span_attribute(value) == result -@pytest.mark.parametrize( - ("input_str", "expected_output"), - ( - ( - "2021-01-01T00:00:00.000000Z", - datetime(2021, 1, 1, tzinfo=timezone.utc), - ), # UTC time - ( - "2021-01-01T00:00:00.000000", - datetime(2021, 1, 1, tzinfo=datetime.now().astimezone().tzinfo), - ), # No TZ -- assume UTC - ( - "2021-01-01T00:00:00Z", - datetime(2021, 1, 1, tzinfo=timezone.utc), - ), # UTC - No milliseconds - ( - "2021-01-01T00:00:00.000000+00:00", - datetime(2021, 1, 1, tzinfo=timezone.utc), - ), - ( - "2021-01-01T00:00:00.000000-00:00", - datetime(2021, 1, 1, tzinfo=timezone.utc), - ), - ( - "2021-01-01T00:00:00.000000+0000", - datetime(2021, 1, 1, tzinfo=timezone.utc), - ), - ( - "2021-01-01T00:00:00.000000-0000", - datetime(2021, 1, 1, tzinfo=timezone.utc), - ), - ( - "2020-12-31T00:00:00.000000+02:00", - datetime(2020, 12, 31, tzinfo=timezone(timedelta(hours=2))), - ), # UTC+2 time - ( - "2020-12-31T00:00:00.000000-0200", - datetime(2020, 12, 31, tzinfo=timezone(timedelta(hours=-2))), - ), # UTC-2 time - ( - "2020-12-31T00:00:00-0200", - datetime(2020, 12, 31, tzinfo=timezone(timedelta(hours=-2))), - ), # UTC-2 time - no milliseconds - ), -) -def test_datetime_from_isoformat(input_str, expected_output): - assert datetime_from_isoformat(input_str) == expected_output, input_str - - def test_qualname_from_function_inner_function(): def test_function(): ... @@ -1005,3 +1043,55 @@ def test_function(): ... sentry_sdk.utils.qualname_from_function(test_function) == "test_qualname_from_function_none_name..test_function" ) + + +def test_to_string_unicode_decode_error(): + class BadStr: + def __str__(self): + raise UnicodeDecodeError("utf-8", b"", 0, 1, "reason") + + obj = BadStr() + result = to_string(obj) + assert result == repr(obj)[1:-1] + + +def test_exc_info_from_error_dont_get_an_exc(): + class NotAnException: + pass + + with pytest.raises(ValueError) as exc: + exc_info_from_error(NotAnException()) + + assert "Expected Exception object to report, got