sentry-structlog 3.0.0__tar.gz → 3.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sentry-structlog
3
- Version: 3.0.0
3
+ Version: 3.0.1
4
4
  Summary: Sentry integration for structlog (thread-safe fork of structlog-sentry)
5
5
  Author-email: "Kiwi.com platform" <platform@kiwi.com>
6
6
  Maintainer: Barsoomx
@@ -207,10 +207,11 @@ The `SentryProcessor` class takes the following arguments:
207
207
  Add `structlog.stdlib.add_log_level` (or `add_log_level_number`) before the
208
208
  processor. `add_logger_name` is optional and also belongs before it. Names are
209
209
  resolved from non-empty strings in `event_dict["logger"]`, `_record.name`, then
210
- the wrapped logger's `name`. If the event dict has no `logger` key, the resolved
211
- name supplies the Sentry event logger and breadcrumb category without changing
212
- original event data. `CapturingLogger` and mock loggers without a string name
213
- are supported.
210
+ the wrapped logger's `name`. The resolved name supplies the Sentry event logger
211
+ and breadcrumb category even when the event dict's `logger` is `None`, empty,
212
+ or not a string, without changing original event data. Without a resolved name,
213
+ non-string values are omitted from the event logger and breadcrumb category.
214
+ `CapturingLogger` and mock loggers without a string name are supported.
214
215
 
215
216
  ### Capture status
216
217
 
@@ -283,6 +284,8 @@ stack in `threads.values`, with `crashed=False` and `current=True`, even when
283
284
  `include_local_variables`, `include_source_context`, and `max_value_length`
284
285
  options. With `attach_stacktrace=True`, the SDK supplies the stack instead.
285
286
  Plain events without either flag only get a stack when that client option is on.
287
+ Stacks captured by the processor omit `sentry_structlog`, `structlog`, and
288
+ `logging` frames so raw log data in their locals cannot bypass context scrubbing.
286
289
 
287
290
  The processor leaves `exc_info`, `stack_info`, and `stack` unchanged for
288
291
  subsequent processors. If `StackInfoRenderer` ran first, the rendered `stack`
@@ -398,11 +401,17 @@ sentry_sdk.init(
398
401
  With this scrubber, `value="secret"` and `nested={"value": "s"}` are filtered in
399
402
  the context, and `value` is omitted from tags. `scrub=False` disables these
400
403
  processor-level protections; it does not disable the tag policy above. If the
401
- client has no event scrubber, the processor leaves context values and eligible
402
- tags unchanged. When configured, the SDK's event scrubber handles breadcrumb
404
+ client has no event scrubber, the processor does not scrub context values or
405
+ eligible tags. When configured, the SDK's event scrubber handles breadcrumb
403
406
  data while preparing the containing event, even with `scrub=False`; the
404
407
  processor does not scrub it a second time.
405
408
 
409
+ Context and breadcrumb data use separate copies of dictionaries, lists, and
410
+ tuples. Repeated references to these containers become `"<cyclic reference>"`,
411
+ including shared containers without a cycle, so recursive scrubbing and
412
+ serialization can finish. This also applies with `scrub=False` or no client
413
+ scrubber. Original log values and callback hints remain unchanged.
414
+
406
415
  ### Scopes and thread isolation
407
416
 
408
417
  Configure shared processors as `SentryProcessor()` without `scope=`. In
@@ -175,10 +175,11 @@ The `SentryProcessor` class takes the following arguments:
175
175
  Add `structlog.stdlib.add_log_level` (or `add_log_level_number`) before the
176
176
  processor. `add_logger_name` is optional and also belongs before it. Names are
177
177
  resolved from non-empty strings in `event_dict["logger"]`, `_record.name`, then
178
- the wrapped logger's `name`. If the event dict has no `logger` key, the resolved
179
- name supplies the Sentry event logger and breadcrumb category without changing
180
- original event data. `CapturingLogger` and mock loggers without a string name
181
- are supported.
178
+ the wrapped logger's `name`. The resolved name supplies the Sentry event logger
179
+ and breadcrumb category even when the event dict's `logger` is `None`, empty,
180
+ or not a string, without changing original event data. Without a resolved name,
181
+ non-string values are omitted from the event logger and breadcrumb category.
182
+ `CapturingLogger` and mock loggers without a string name are supported.
182
183
 
183
184
  ### Capture status
184
185
 
@@ -251,6 +252,8 @@ stack in `threads.values`, with `crashed=False` and `current=True`, even when
251
252
  `include_local_variables`, `include_source_context`, and `max_value_length`
252
253
  options. With `attach_stacktrace=True`, the SDK supplies the stack instead.
253
254
  Plain events without either flag only get a stack when that client option is on.
255
+ Stacks captured by the processor omit `sentry_structlog`, `structlog`, and
256
+ `logging` frames so raw log data in their locals cannot bypass context scrubbing.
254
257
 
255
258
  The processor leaves `exc_info`, `stack_info`, and `stack` unchanged for
256
259
  subsequent processors. If `StackInfoRenderer` ran first, the rendered `stack`
@@ -366,11 +369,17 @@ sentry_sdk.init(
366
369
  With this scrubber, `value="secret"` and `nested={"value": "s"}` are filtered in
367
370
  the context, and `value` is omitted from tags. `scrub=False` disables these
368
371
  processor-level protections; it does not disable the tag policy above. If the
369
- client has no event scrubber, the processor leaves context values and eligible
370
- tags unchanged. When configured, the SDK's event scrubber handles breadcrumb
372
+ client has no event scrubber, the processor does not scrub context values or
373
+ eligible tags. When configured, the SDK's event scrubber handles breadcrumb
371
374
  data while preparing the containing event, even with `scrub=False`; the
372
375
  processor does not scrub it a second time.
373
376
 
377
+ Context and breadcrumb data use separate copies of dictionaries, lists, and
378
+ tuples. Repeated references to these containers become `"<cyclic reference>"`,
379
+ including shared containers without a cycle, so recursive scrubbing and
380
+ serialization can finish. This also applies with `scrub=False` or no client
381
+ scrubber. Original log values and callback hints remain unchanged.
382
+
374
383
  ### Scopes and thread isolation
375
384
 
376
385
  Configure shared processors as `SentryProcessor()` without `scope=`. In
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "sentry-structlog"
7
- version = "3.0.0"
7
+ version = "3.0.1"
8
8
  description = "Sentry integration for structlog (thread-safe fork of structlog-sentry)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -50,6 +50,7 @@ dev = [
50
50
  "ruff==0.15.17",
51
51
  "mypy==1.20.0",
52
52
  "twine>=6",
53
+ "pyright>=1.1.400",
53
54
  ]
54
55
 
55
56
  [tool.setuptools.packages.find]
@@ -29,7 +29,7 @@ try:
29
29
  from structlog.processors import NAME_TO_LEVEL
30
30
  except ImportError: # Older structlog versions expose the same mapping privately.
31
31
  from structlog.processors import ( # type: ignore[attr-defined,no-redef]
32
- _NAME_TO_LEVEL as NAME_TO_LEVEL,
32
+ _NAME_TO_LEVEL as NAME_TO_LEVEL, # pyright: ignore[reportAttributeAccessIssue]
33
33
  )
34
34
 
35
35
 
@@ -54,6 +54,7 @@ _TAG_KEY_PATTERN = re.compile(r"[a-zA-Z0-9_.:-]{1,32}")
54
54
 
55
55
 
56
56
  def _to_tag_value(value: object) -> str | None:
57
+ __tracebackhide__ = True
57
58
  if not isinstance(value, (str, int, float, bool, UUID, Decimal, Enum)):
58
59
  return None
59
60
  value = str(value)
@@ -62,13 +63,22 @@ def _to_tag_value(value: object) -> str | None:
62
63
  return value
63
64
 
64
65
 
65
- def _copy_scrub_data(value: Any) -> Any:
66
- """Copy containers the scrubber mutates, leaving opaque log values untouched."""
66
+ def _copy_scrub_data(value: Any, memo: set[int] | None = None) -> Any:
67
+ """Copy containers, replacing repeated references and preserving opaque values."""
68
+ __tracebackhide__ = True
69
+ if not isinstance(value, (dict, list, tuple)):
70
+ return value
71
+ if memo is None:
72
+ memo = set()
73
+ identity = id(value)
74
+ if identity in memo:
75
+ return "<cyclic reference>"
76
+ memo.add(identity)
67
77
  if isinstance(value, dict):
68
- return {key: _copy_scrub_data(item) for key, item in value.items()}
78
+ return {key: _copy_scrub_data(item, memo) for key, item in value.items()}
69
79
  if isinstance(value, list):
70
- return [_copy_scrub_data(item) for item in value]
71
- return value
80
+ return [_copy_scrub_data(item, memo) for item in value]
81
+ return tuple(_copy_scrub_data(item, memo) for item in value)
72
82
 
73
83
 
74
84
  def _figure_out_exc_info(v: Any) -> ExcInfo | None:
@@ -77,6 +87,7 @@ def _figure_out_exc_info(v: Any) -> ExcInfo | None:
77
87
  to transform *v* into an ``exc_info`` tuple. Falsy flags become ``None``;
78
88
  the empty ``sys.exc_info()`` tuple is preserved to request a stack trace.
79
89
  """
90
+ __tracebackhide__ = True
80
91
  if isinstance(v, BaseException):
81
92
  return (v.__class__, v, v.__traceback__)
82
93
  elif isinstance(v, tuple):
@@ -172,6 +183,7 @@ class SentryProcessor:
172
183
  :param logger: logger instance
173
184
  :param event_dict: structlog event_dict
174
185
  """
186
+ __tracebackhide__ = True
175
187
  l_name = event_dict.get("logger")
176
188
  if isinstance(l_name, str) and l_name:
177
189
  return l_name
@@ -187,10 +199,12 @@ class SentryProcessor:
187
199
  return None
188
200
 
189
201
  def _get_scope(self) -> Scope:
202
+ __tracebackhide__ = True
190
203
  return self._scope or get_isolation_scope()
191
204
 
192
205
  @staticmethod
193
206
  def _get_hint(event_dict: EventDict) -> Hint:
207
+ __tracebackhide__ = True
194
208
  hint: Hint = {"structlog": dict(event_dict)}
195
209
  record = event_dict.get("_record")
196
210
  if isinstance(record, logging.LogRecord):
@@ -207,6 +221,7 @@ class SentryProcessor:
207
221
  `sentry_skip`, before capturing the event; used for tags and context.
208
222
  Defaults to `event_dict` itself.
209
223
  """
224
+ __tracebackhide__ = True
210
225
  if original_event_dict is None:
211
226
  original_event_dict = event_dict
212
227
 
@@ -222,24 +237,34 @@ class SentryProcessor:
222
237
  mechanism={"type": "structlog", "handled": True},
223
238
  )
224
239
  else:
225
- event, hint = {}, {}
240
+ event = {}
241
+ hint = {}
226
242
  # The SDK client supplies its own stack when attach_stacktrace is set.
227
243
  if (exc_info or event_dict.get("stack_info")) and not options.get(
228
244
  "attach_stacktrace"
229
245
  ):
230
246
  with capture_internal_exceptions():
247
+ stacktrace = current_stacktrace(
248
+ include_local_variables=options.get(
249
+ "include_local_variables", True
250
+ ),
251
+ include_source_context=options.get(
252
+ "include_source_context", True
253
+ ),
254
+ max_value_length=options.get("max_value_length"),
255
+ )
256
+ # Nonrecursive SDK scrubbing cannot clean nested event_dict locals.
257
+ stacktrace["frames"] = [
258
+ frame
259
+ for frame in stacktrace["frames"]
260
+ if not (frame.get("module") or "").startswith(
261
+ ("sentry_structlog", "structlog", "logging")
262
+ )
263
+ ]
231
264
  event["threads"] = {
232
265
  "values": [
233
266
  {
234
- "stacktrace": current_stacktrace(
235
- include_local_variables=options.get(
236
- "include_local_variables", True
237
- ),
238
- include_source_context=options.get(
239
- "include_source_context", True
240
- ),
241
- max_value_length=options.get("max_value_length"),
242
- ),
267
+ "stacktrace": stacktrace,
243
268
  "crashed": False,
244
269
  "current": True,
245
270
  }
@@ -249,8 +274,9 @@ class SentryProcessor:
249
274
  # Structlog permits missing/dynamic values; preserve them for the SDK.
250
275
  event["message"] = event_dict.get("event") # type: ignore[typeddict-item]
251
276
  event["level"] = event_dict.get("level") # type: ignore[typeddict-item]
252
- if "logger" in event_dict:
253
- event["logger"] = event_dict["logger"]
277
+ logger_name = event_dict.get("logger")
278
+ if isinstance(logger_name, str):
279
+ event["logger"] = logger_name
254
280
 
255
281
  scrubber = (
256
282
  self._get_scope().get_client().options.get("event_scrubber")
@@ -258,11 +284,7 @@ class SentryProcessor:
258
284
  else None
259
285
  )
260
286
  if self._as_context:
261
- context = (
262
- _copy_scrub_data(original_event_dict)
263
- if scrubber is not None and scrubber.recursive
264
- else dict(original_event_dict)
265
- )
287
+ context = _copy_scrub_data(original_event_dict)
266
288
  if isinstance(context.get("_record"), logging.LogRecord):
267
289
  context.pop("_record")
268
290
  if scrubber is not None:
@@ -292,24 +314,27 @@ class SentryProcessor:
292
314
  def _get_breadcrumb_and_hint(
293
315
  self, event_dict: EventDict
294
316
  ) -> tuple[Breadcrumb, Hint]:
317
+ __tracebackhide__ = True
295
318
  data = {
296
319
  k: v
297
320
  for k, v in event_dict.items()
298
321
  if k not in self.ignore_breadcrumb_data
299
322
  and not (k == "_record" and isinstance(v, logging.LogRecord))
300
323
  }
324
+ logger_name = event_dict.get("logger")
301
325
  event: Breadcrumb = {
302
326
  "type": "log",
303
327
  "level": event_dict.get("level"),
304
- "category": event_dict.get("logger"),
328
+ "category": logger_name if isinstance(logger_name, str) else None,
305
329
  "message": event_dict["event"],
306
330
  "timestamp": event_dict.get("timestamp"),
307
- "data": data,
331
+ "data": _copy_scrub_data(data),
308
332
  }
309
333
 
310
334
  return event, self._get_hint(event_dict)
311
335
 
312
336
  def _can_record(self, logger_name: str | None, event_dict: EventDict) -> bool:
337
+ __tracebackhide__ = True
313
338
  if logger_name:
314
339
  for ignored_logger in _IGNORED_LOGGERS | self._ignored_loggers:
315
340
  if fnmatch(logger_name, ignored_logger):
@@ -325,9 +350,10 @@ class SentryProcessor:
325
350
  sentry_level: LogLevelStr | None = None,
326
351
  logger_name: str | None = None,
327
352
  ) -> None:
353
+ __tracebackhide__ = True
328
354
  with capture_internal_exceptions():
329
355
  event, hint = self._get_event_and_hint(event_dict, original_event_dict)
330
- if "logger" not in event_dict and logger_name is not None:
356
+ if logger_name is not None:
331
357
  event["logger"] = logger_name
332
358
  if sentry_level is not None:
333
359
  event["level"] = sentry_level
@@ -343,9 +369,10 @@ class SentryProcessor:
343
369
  sentry_level: str | None = None,
344
370
  logger_name: str | None = None,
345
371
  ) -> None:
372
+ __tracebackhide__ = True
346
373
  with capture_internal_exceptions():
347
374
  event, hint = self._get_breadcrumb_and_hint(event_dict)
348
- if "logger" not in event_dict and logger_name is not None:
375
+ if logger_name is not None:
349
376
  event["category"] = logger_name
350
377
  if sentry_level is not None:
351
378
  event["level"] = sentry_level
@@ -354,6 +381,7 @@ class SentryProcessor:
354
381
  @staticmethod
355
382
  def _resolve_level(event_dict: EventDict) -> int | None:
356
383
  """Prefer a numeric level, then structlog aliases and registered names."""
384
+ __tracebackhide__ = True
357
385
  level = event_dict.get("level_number")
358
386
  if isinstance(level, int):
359
387
  return level
@@ -375,6 +403,7 @@ class SentryProcessor:
375
403
  @staticmethod
376
404
  def _get_sentry_level(level: int) -> LogLevelStr:
377
405
  """Map numeric ranges to Sentry severities, including custom levels."""
406
+ __tracebackhide__ = True
378
407
  if level >= logging.CRITICAL:
379
408
  return "fatal"
380
409
  if level >= logging.ERROR:
@@ -389,6 +418,7 @@ class SentryProcessor:
389
418
  self, logger: WrappedLogger, name: str, event_dict: EventDict
390
419
  ) -> EventDict:
391
420
  """A middleware to process structlog `event_dict` and send it to Sentry."""
421
+ __tracebackhide__ = True
392
422
  try:
393
423
  with capture_internal_exceptions():
394
424
  sentry_skip = event_dict.pop("sentry_skip", False)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sentry-structlog
3
- Version: 3.0.0
3
+ Version: 3.0.1
4
4
  Summary: Sentry integration for structlog (thread-safe fork of structlog-sentry)
5
5
  Author-email: "Kiwi.com platform" <platform@kiwi.com>
6
6
  Maintainer: Barsoomx
@@ -207,10 +207,11 @@ The `SentryProcessor` class takes the following arguments:
207
207
  Add `structlog.stdlib.add_log_level` (or `add_log_level_number`) before the
208
208
  processor. `add_logger_name` is optional and also belongs before it. Names are
209
209
  resolved from non-empty strings in `event_dict["logger"]`, `_record.name`, then
210
- the wrapped logger's `name`. If the event dict has no `logger` key, the resolved
211
- name supplies the Sentry event logger and breadcrumb category without changing
212
- original event data. `CapturingLogger` and mock loggers without a string name
213
- are supported.
210
+ the wrapped logger's `name`. The resolved name supplies the Sentry event logger
211
+ and breadcrumb category even when the event dict's `logger` is `None`, empty,
212
+ or not a string, without changing original event data. Without a resolved name,
213
+ non-string values are omitted from the event logger and breadcrumb category.
214
+ `CapturingLogger` and mock loggers without a string name are supported.
214
215
 
215
216
  ### Capture status
216
217
 
@@ -283,6 +284,8 @@ stack in `threads.values`, with `crashed=False` and `current=True`, even when
283
284
  `include_local_variables`, `include_source_context`, and `max_value_length`
284
285
  options. With `attach_stacktrace=True`, the SDK supplies the stack instead.
285
286
  Plain events without either flag only get a stack when that client option is on.
287
+ Stacks captured by the processor omit `sentry_structlog`, `structlog`, and
288
+ `logging` frames so raw log data in their locals cannot bypass context scrubbing.
286
289
 
287
290
  The processor leaves `exc_info`, `stack_info`, and `stack` unchanged for
288
291
  subsequent processors. If `StackInfoRenderer` ran first, the rendered `stack`
@@ -398,11 +401,17 @@ sentry_sdk.init(
398
401
  With this scrubber, `value="secret"` and `nested={"value": "s"}` are filtered in
399
402
  the context, and `value` is omitted from tags. `scrub=False` disables these
400
403
  processor-level protections; it does not disable the tag policy above. If the
401
- client has no event scrubber, the processor leaves context values and eligible
402
- tags unchanged. When configured, the SDK's event scrubber handles breadcrumb
404
+ client has no event scrubber, the processor does not scrub context values or
405
+ eligible tags. When configured, the SDK's event scrubber handles breadcrumb
403
406
  data while preparing the containing event, even with `scrub=False`; the
404
407
  processor does not scrub it a second time.
405
408
 
409
+ Context and breadcrumb data use separate copies of dictionaries, lists, and
410
+ tuples. Repeated references to these containers become `"<cyclic reference>"`,
411
+ including shared containers without a cycle, so recursive scrubbing and
412
+ serialization can finish. This also applies with `scrub=False` or no client
413
+ scrubber. Original log values and callback hints remain unchanged.
414
+
406
415
  ### Scopes and thread isolation
407
416
 
408
417
  Configure shared processors as `SentryProcessor()` without `scope=`. In
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import logging
2
3
  import sys
3
4
  import threading
@@ -5,8 +6,9 @@ from concurrent.futures import ThreadPoolExecutor
5
6
  from dataclasses import dataclass, field
6
7
  from decimal import Decimal
7
8
  from enum import Enum
9
+ from io import StringIO
8
10
  from unittest.mock import Mock
9
- from uuid import UUID
11
+ from uuid import UUID, uuid4
10
12
 
11
13
  import pytest
12
14
  import sentry_sdk
@@ -487,6 +489,60 @@ def test_per_call_stack_without_exception(sentry_events, flags):
487
489
  assert {key: downstream[key] for key in flags} == flags
488
490
 
489
491
 
492
+ @pytest.mark.parametrize(
493
+ "sentry_events", [{"include_local_variables": True}], indirect=True
494
+ )
495
+ @pytest.mark.parametrize("flag", ["stack_info", "exc_info"])
496
+ @pytest.mark.parametrize("use_formatter", [False, True])
497
+ def test_per_call_stack_does_not_leak_log_password(sentry_events, flag, use_formatter):
498
+ processor = SentryProcessor(tag_keys="__all__")
499
+ if use_formatter:
500
+ logger = logging.Logger("stack.logger")
501
+ handler = logging.StreamHandler(StringIO())
502
+ handler.setFormatter(
503
+ structlog.stdlib.ProcessorFormatter(
504
+ processors=[
505
+ processor,
506
+ structlog.stdlib.ProcessorFormatter.remove_processors_meta,
507
+ structlog.processors.JSONRenderer(),
508
+ ]
509
+ )
510
+ )
511
+ logger.addHandler(handler)
512
+ processors = [structlog.stdlib.ProcessorFormatter.wrap_for_formatter]
513
+ else:
514
+ logger = structlog.ReturnLogger()
515
+ processors = [processor]
516
+ log = structlog.wrap_logger(
517
+ logger,
518
+ wrapper_class=structlog.stdlib.BoundLogger,
519
+ processors=[structlog.stdlib.add_log_level, *processors],
520
+ )
521
+ # A generated value avoids embedding the password in captured source context.
522
+ password = str(uuid4())
523
+ assert sys.exc_info() == (None, None, None)
524
+
525
+ log.error("stack requested", password=password, **{flag: True})
526
+
527
+ [event] = sentry_events
528
+ assert password not in json.dumps(event, default=str)
529
+ assert event["contexts"]["structlog"]["password"] == "[Filtered]"
530
+ assert event["tags"] == {}
531
+ frames = event["threads"]["values"][0]["stacktrace"]["frames"]
532
+ assert all(
533
+ not (frame.get("module") or "").startswith(
534
+ ("sentry_structlog", "structlog", "logging")
535
+ )
536
+ for frame in frames
537
+ )
538
+ [test_frame] = [
539
+ frame
540
+ for frame in frames
541
+ if frame["function"] == "test_per_call_stack_does_not_leak_log_password"
542
+ ]
543
+ assert test_frame["vars"]["password"] == "[Filtered]"
544
+
545
+
490
546
  @pytest.mark.parametrize("value", [None, False, 0, "", []])
491
547
  def test_falsy_exc_info_is_normalized_to_none(value):
492
548
  assert _figure_out_exc_info(value) is None
@@ -745,6 +801,13 @@ def test_logger_name_is_resolved_once_for_filter_and_payloads(sentry_events):
745
801
  "logger, record_name, event_name, expected_name",
746
802
  [
747
803
  (logging.getLogger("wrapped.logger"), absent, absent, "wrapped.logger"),
804
+ (MockLogger("wrapped.logger"), absent, None, "wrapped.logger"),
805
+ (MockLogger("wrapped.logger"), absent, 42, "wrapped.logger"),
806
+ (MockLogger("wrapped.logger"), absent, "", "wrapped.logger"),
807
+ (None, "record.logger", absent, "record.logger"),
808
+ (MockLogger("wrapped.logger"), "record.logger", None, "record.logger"),
809
+ (MockLogger("wrapped.logger"), "record.logger", 42, "record.logger"),
810
+ (MockLogger("wrapped.logger"), "record.logger", "", "record.logger"),
748
811
  (Mock(), "record.logger", absent, "record.logger"),
749
812
  (structlog.testing.CapturingLogger(), "record.logger", absent, "record.logger"),
750
813
  (MockLogger(None), "record.logger", absent, "record.logger"),
@@ -782,6 +845,23 @@ def test_resolved_logger_name_is_used_in_payloads(
782
845
  assert ("logger" in first["contexts"]["structlog"]) == (event_name is not absent)
783
846
 
784
847
 
848
+ @pytest.mark.parametrize("event_name", [None, 42, Mock(), lambda: "name"])
849
+ def test_nonstring_logger_without_fallback_is_omitted(sentry_events, event_name):
850
+ processor = SentryProcessor()
851
+ event_data = {"level": "error", "event": "first", "logger": event_name}
852
+
853
+ event, _ = processor._get_event_and_hint(event_data)
854
+ assert "logger" not in event
855
+ processor(None, "error", event_data)
856
+ sentry_sdk.capture_message("flush breadcrumb")
857
+
858
+ first, second = sentry_events
859
+ assert "logger" not in first
860
+ [breadcrumb] = second["breadcrumbs"]["values"]
861
+ assert breadcrumb.get("category") is None
862
+ assert event_data["logger"] is event_name
863
+
864
+
785
865
  @pytest.mark.parametrize("event_name", [None, "", 42, Mock(), lambda: "name"])
786
866
  def test_invalid_event_logger_name_falls_back_for_ignore_filter(
787
867
  sentry_events, event_name
@@ -1446,6 +1526,92 @@ def test_scrubbing_respects_nonrecursive_client_setting(sentry_events):
1446
1526
  assert event["contexts"]["structlog"]["nested"] == {"value": "s"}
1447
1527
 
1448
1528
 
1529
+ @pytest.mark.parametrize(
1530
+ "sentry_events",
1531
+ [
1532
+ {"event_scrubber": EventScrubber(recursive=True)},
1533
+ {"event_scrubber": EventScrubber()},
1534
+ ],
1535
+ indirect=True,
1536
+ ids=["recursive", "nonrecursive"],
1537
+ )
1538
+ @pytest.mark.parametrize("scrub", [True, False])
1539
+ @pytest.mark.parametrize("container", ["dict", "list", "tuple"])
1540
+ def test_cyclic_kwargs_are_delivered_without_mutation(
1541
+ sentry_events, sentry_hints, scrub, container
1542
+ ):
1543
+ if container == "dict":
1544
+ value = {}
1545
+ value["self"] = value
1546
+ expected = {"self": "<cyclic reference>"}
1547
+ elif container == "list":
1548
+ value = []
1549
+ value.append({"self": value})
1550
+ expected = [{"self": "<cyclic reference>"}]
1551
+ else:
1552
+ items = []
1553
+ value = (items,)
1554
+ items.append(value)
1555
+ expected = [["<cyclic reference>"]]
1556
+ nested = {"child": {"cycle": value, "password": "nested password"}}
1557
+ log = structlog.wrap_logger(
1558
+ structlog.ReturnLogger(),
1559
+ processors=[
1560
+ structlog.stdlib.add_log_level,
1561
+ SentryProcessor(scrub=scrub, tag_keys="__all__"),
1562
+ ],
1563
+ )
1564
+
1565
+ _, downstream = log.error("cyclic kwargs", nested=nested, password="top password")
1566
+ sentry_sdk.capture_message("flush breadcrumb")
1567
+
1568
+ first, second = sentry_events
1569
+ context = first["contexts"]["structlog"]
1570
+ assert context["nested"]["child"]["cycle"] == expected
1571
+ assert context["password"] == ("[Filtered]" if scrub else "top password")
1572
+ recursive = sentry_sdk.get_client().options["event_scrubber"].recursive
1573
+ assert context["nested"]["child"]["password"] == (
1574
+ "[Filtered]" if scrub and recursive else "nested password"
1575
+ )
1576
+ assert first["tags"] == ({} if scrub else {"password": "top password"})
1577
+ [breadcrumb] = second["breadcrumbs"]["values"]
1578
+ # The SDK may stringify deeply nested breadcrumb values at its depth limit.
1579
+ assert "<cyclic reference>" in json.dumps(
1580
+ breadcrumb["data"]["nested"]["child"]["cycle"]
1581
+ )
1582
+ assert breadcrumb["data"]["password"] == "[Filtered]"
1583
+ assert breadcrumb["data"]["nested"]["child"]["password"] == (
1584
+ "[Filtered]" if recursive else "nested password"
1585
+ )
1586
+ assert downstream["nested"] is nested
1587
+ assert nested["child"]["password"] == "nested password"
1588
+ assert downstream["password"] == "top password"
1589
+ if container == "dict":
1590
+ assert value["self"] is value
1591
+ elif container == "list":
1592
+ assert value[0]["self"] is value
1593
+ else:
1594
+ assert value[0][0] is value
1595
+ assert sentry_hints["events"][0]["structlog"]["nested"] is nested
1596
+ assert sentry_hints["breadcrumbs"][0]["structlog"]["nested"] is nested
1597
+
1598
+
1599
+ @pytest.mark.parametrize("container", [dict, list, tuple])
1600
+ def test_repeated_containers_use_cycle_marker(sentry_events, container):
1601
+ value = container()
1602
+ SentryProcessor(tag_keys="__all__")(
1603
+ None, "error", {"level": "error", "event": "aliases", "values": [value, value]}
1604
+ )
1605
+ sentry_sdk.capture_message("flush breadcrumb")
1606
+
1607
+ first, second = sentry_events
1608
+ expected = [{} if container is dict else [], "<cyclic reference>"]
1609
+ assert first["contexts"]["structlog"]["values"] == expected
1610
+ assert first["tags"] == {}
1611
+ [breadcrumb] = second["breadcrumbs"]["values"]
1612
+ assert breadcrumb["data"]["values"] == expected
1613
+
1614
+
1449
1615
  def test_capture_without_client_scrubber(sentry_events, monkeypatch):
1450
1616
  client = sentry_sdk.get_client()
1451
1617
  monkeypatch.setitem(client.options, "event_scrubber", None)