log-foundry 0.10.2.dev73__tar.gz → 0.10.2.dev75__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.
Files changed (59) hide show
  1. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/PKG-INFO +23 -6
  2. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/README.md +22 -5
  3. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/pyproject.toml +1 -1
  4. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/sentry.py +195 -18
  5. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/LICENSE +0 -0
  6. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/__init__.py +0 -0
  7. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/_diag.py +0 -0
  8. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/_fork.py +0 -0
  9. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/_lifecycle.py +0 -0
  10. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/api.py +0 -0
  11. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/config.py +0 -0
  12. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/console.py +0 -0
  13. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/context.py +0 -0
  14. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/decorator.py +0 -0
  15. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/ids.py +0 -0
  16. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/model.py +0 -0
  17. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/py.typed +0 -0
  18. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/results.py +0 -0
  19. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sanitize.py +0 -0
  20. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/__init__.py +0 -0
  21. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/_batch.py +0 -0
  22. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/_chunk.py +0 -0
  23. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/_retry.py +0 -0
  24. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/_socket.py +0 -0
  25. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/_time.py +0 -0
  26. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/base.py +0 -0
  27. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/callback.py +0 -0
  28. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/clickhouse.py +0 -0
  29. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/datadog.py +0 -0
  30. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/elasticsearch.py +0 -0
  31. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/eventhubs.py +0 -0
  32. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/file.py +0 -0
  33. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/filtering.py +0 -0
  34. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/firehose.py +0 -0
  35. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/honeycomb.py +0 -0
  36. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/http.py +0 -0
  37. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/kafka.py +0 -0
  38. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/kinesis.py +0 -0
  39. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/logging_sink.py +0 -0
  40. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/logstash.py +0 -0
  41. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/loki.py +0 -0
  42. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/memory.py +0 -0
  43. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/mongodb.py +0 -0
  44. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/multi.py +0 -0
  45. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/nats.py +0 -0
  46. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/newrelic.py +0 -0
  47. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/null.py +0 -0
  48. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/postgres.py +0 -0
  49. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/pubsub.py +0 -0
  50. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/rabbitmq.py +0 -0
  51. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/redis.py +0 -0
  52. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/sns.py +0 -0
  53. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/splunk.py +0 -0
  54. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/sqlite.py +0 -0
  55. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/sqs.py +0 -0
  56. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/stdout.py +0 -0
  57. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/syslog.py +0 -0
  58. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/sinks/transform.py +0 -0
  59. {log_foundry-0.10.2.dev73 → log_foundry-0.10.2.dev75}/src/log_foundry/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.10.2.dev73
3
+ Version: 0.10.2.dev75
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -706,7 +706,7 @@ lf.configure(sink=ElasticsearchSink("https://es.internal:9200", index="app-logs"
706
706
  #### SaaS platforms
707
707
 
708
708
  Also HTTP-based. All are zero-dependency **except** `SentrySink`, which prefers the `sentry-sdk`
709
- (the `sentry` extra) and falls back to raw HTTP envelopes when it isn't installed.
709
+ (the `sentry` extra) and falls back to raw HTTP envelopes when it cannot deliver through one.
710
710
 
711
711
  | Sink | Import from | Extra | Configure |
712
712
  |---|---|---|---|
@@ -714,11 +714,28 @@ Also HTTP-based. All are zero-dependency **except** `SentrySink`, which prefers
714
714
  | `SplunkHECSink` | `log_foundry.sinks.splunk` | — | `SplunkHECSink(url, token, *, host=None, source="log-foundry")` — HTTP Event Collector |
715
715
  | `NewRelicSink` | `log_foundry.sinks.newrelic` | — | `NewRelicSink(api_key, *, region="US")` — `region` is `"US"` or `"EU"` |
716
716
  | `HoneycombSink` | `log_foundry.sinks.honeycomb` | — | `HoneycombSink(api_key, dataset, *, url="https://api.honeycomb.io")` |
717
- | `SentrySink` | `log_foundry.sinks.sentry` | `sentry` | `SentrySink(dsn=None, *, min_level="ERROR")` — sends only `min_level`+ events |
717
+ | `SentrySink` | `log_foundry.sinks.sentry` | `sentry` | `SentrySink(dsn=None, *, min_level="ERROR", backend="auto")` — sends only `min_level`+ events |
718
718
 
719
- With the `sentry` extra installed, `SentrySink` captures via `sentry_sdk.capture_event` (initialize
720
- the SDK yourself with `sentry_sdk.init(...)`); without it, pass `dsn=` and events are POSTed as
721
- Sentry envelopes over HTTP.
719
+ `SentrySink` captures via `sentry_sdk.capture_event` when the SDK can deliver — you initialize it
720
+ yourself with `sentry_sdk.init(...)` — and POSTs Sentry envelopes over HTTP to `dsn=` otherwise.
721
+ With neither a deliverable SDK nor a `dsn=`, a batch is **refused** rather than silently dropped.
722
+ `backend=` decides:
723
+
724
+ | `backend=` | What it uses |
725
+ |---|---|
726
+ | `"auto"` (default) | the SDK when it can deliver, otherwise the HTTP fallback; re-decided on every batch, so an `init(...)` that arrives after the sink was built is picked up |
727
+ | `"sdk"` | the SDK only. A client that cannot deliver **refuses the batch** rather than quietly switching transport |
728
+ | `"http"` | the HTTP fallback only. No SDK is held, consulted or flushed |
729
+
730
+ "Can deliver" means the SDK's client reports itself active *and* holds a transport. An
731
+ uninitialized process, an `init()` with no DSN, and a `close()`d client all fail that — the first
732
+ reports itself inactive, the other two report themselves active with nothing to send through, and
733
+ all three drop events silently.
734
+
735
+ An argument the chosen backend can never use is a `ValueError` rather than a silent ignore:
736
+ `opener=` where no HTTP fallback is built, `client=` under `backend="http"`. Until this release
737
+ `opener=` was accepted and then ignored whenever the SDK happened to import — including when it was
738
+ installed as somebody else's transitive dependency.
722
739
 
723
740
  #### AWS — the durable-buffer path (`aws` extra)
724
741
 
@@ -670,7 +670,7 @@ lf.configure(sink=ElasticsearchSink("https://es.internal:9200", index="app-logs"
670
670
  #### SaaS platforms
671
671
 
672
672
  Also HTTP-based. All are zero-dependency **except** `SentrySink`, which prefers the `sentry-sdk`
673
- (the `sentry` extra) and falls back to raw HTTP envelopes when it isn't installed.
673
+ (the `sentry` extra) and falls back to raw HTTP envelopes when it cannot deliver through one.
674
674
 
675
675
  | Sink | Import from | Extra | Configure |
676
676
  |---|---|---|---|
@@ -678,11 +678,28 @@ Also HTTP-based. All are zero-dependency **except** `SentrySink`, which prefers
678
678
  | `SplunkHECSink` | `log_foundry.sinks.splunk` | — | `SplunkHECSink(url, token, *, host=None, source="log-foundry")` — HTTP Event Collector |
679
679
  | `NewRelicSink` | `log_foundry.sinks.newrelic` | — | `NewRelicSink(api_key, *, region="US")` — `region` is `"US"` or `"EU"` |
680
680
  | `HoneycombSink` | `log_foundry.sinks.honeycomb` | — | `HoneycombSink(api_key, dataset, *, url="https://api.honeycomb.io")` |
681
- | `SentrySink` | `log_foundry.sinks.sentry` | `sentry` | `SentrySink(dsn=None, *, min_level="ERROR")` — sends only `min_level`+ events |
681
+ | `SentrySink` | `log_foundry.sinks.sentry` | `sentry` | `SentrySink(dsn=None, *, min_level="ERROR", backend="auto")` — sends only `min_level`+ events |
682
682
 
683
- With the `sentry` extra installed, `SentrySink` captures via `sentry_sdk.capture_event` (initialize
684
- the SDK yourself with `sentry_sdk.init(...)`); without it, pass `dsn=` and events are POSTed as
685
- Sentry envelopes over HTTP.
683
+ `SentrySink` captures via `sentry_sdk.capture_event` when the SDK can deliver — you initialize it
684
+ yourself with `sentry_sdk.init(...)` — and POSTs Sentry envelopes over HTTP to `dsn=` otherwise.
685
+ With neither a deliverable SDK nor a `dsn=`, a batch is **refused** rather than silently dropped.
686
+ `backend=` decides:
687
+
688
+ | `backend=` | What it uses |
689
+ |---|---|
690
+ | `"auto"` (default) | the SDK when it can deliver, otherwise the HTTP fallback; re-decided on every batch, so an `init(...)` that arrives after the sink was built is picked up |
691
+ | `"sdk"` | the SDK only. A client that cannot deliver **refuses the batch** rather than quietly switching transport |
692
+ | `"http"` | the HTTP fallback only. No SDK is held, consulted or flushed |
693
+
694
+ "Can deliver" means the SDK's client reports itself active *and* holds a transport. An
695
+ uninitialized process, an `init()` with no DSN, and a `close()`d client all fail that — the first
696
+ reports itself inactive, the other two report themselves active with nothing to send through, and
697
+ all three drop events silently.
698
+
699
+ An argument the chosen backend can never use is a `ValueError` rather than a silent ignore:
700
+ `opener=` where no HTTP fallback is built, `client=` under `backend="http"`. Until this release
701
+ `opener=` was accepted and then ignored whenever the SDK happened to import — including when it was
702
+ installed as somebody else's transitive dependency.
686
703
 
687
704
  #### AWS — the durable-buffer path (`aws` extra)
688
705
 
@@ -20,7 +20,7 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  # Optional features. Install with: pip install log-foundry[aws]
23
- version = "0.10.2.dev73"
23
+ version = "0.10.2.dev75"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import json
6
6
  import threading
7
7
  import uuid
8
- from typing import Any
8
+ from typing import Any, Final, Literal, get_args
9
9
  from urllib.parse import urlparse
10
10
 
11
11
  from log_foundry import _diag, _lifecycle
@@ -14,6 +14,17 @@ from log_foundry.sinks.http import HTTPSink
14
14
 
15
15
  __all__ = ["SentrySink"]
16
16
 
17
+ Backend = Literal["auto", "sdk", "http"]
18
+ """Which transport a :class:`SentrySink` uses. Not exported: callers pass the literals."""
19
+
20
+ _BACKENDS: Final = get_args(Backend)
21
+
22
+ _Selected = Literal["sdk", "http"]
23
+ """A backend actually chosen for one batch -- never ``"auto"``, which selects rather than is."""
24
+
25
+ _ABSENT: Final = object()
26
+ """Sentinel telling an absent member from one whose value is ``None`` (SPEC-043 FR-001)."""
27
+
17
28
  _LEVEL_RANK = {"DEBUG": 10, "INFO": 20, "WARNING": 30, "ERROR": 40, "CRITICAL": 50}
18
29
  _SENTRY_LEVEL = {
19
30
  "DEBUG": "debug", "INFO": "info", "WARNING": "warning", "ERROR": "error", "CRITICAL": "fatal",
@@ -23,12 +34,39 @@ _SENTRY_LEVEL = {
23
34
  class SentrySink:
24
35
  """A :class:`~log_foundry.sinks.base.Sink` that captures qualifying events to Sentry (FR-011).
25
36
 
26
- It uses the ``sentry-sdk`` when the optional extra is installed, imported lazily inside the
27
- sink so importing this module never requires it. Without the SDK it falls back to POSTing a
28
- Sentry envelope over HTTP to the DSN's ingest URL. Only events at or above the configured
29
- minimum level are sent.
37
+ It uses the ``sentry-sdk`` when one is installed *and able to deliver*, imported lazily inside
38
+ the sink so importing this module never requires it. Otherwise it POSTs a Sentry envelope over
39
+ HTTP to the DSN's ingest URL. Only events at or above the configured minimum level are sent.
40
+
41
+ ``backend`` selects explicitly and ``"auto"`` is the default (SPEC-043 FR-002). An explicit
42
+ selection is honoured rather than substituted: under ``"sdk"`` a client that cannot deliver is
43
+ refused, never diverted to HTTP, and under ``"http"`` no client is held, consulted or flushed.
44
+ What is built, and what each ``emit`` then does:
45
+
46
+ =========== ========== ====== ============== =========== ==================================
47
+ ``backend`` client? DSN? ``self.client`` ``_http`` Per emit
48
+ =========== ========== ====== ============== =========== ==================================
49
+ ``auto`` yes yes the client built SDK if it can deliver, else HTTP
50
+ ``auto`` yes no the client ``None`` SDK if it can deliver, else refuse
51
+ ``auto`` no yes ``None`` built HTTP
52
+ ``auto`` no no — — ``ValueError`` at construction
53
+ ``sdk`` yes either the client ``None`` SDK if it can deliver, else refuse
54
+ ``sdk`` no either — — ``ValueError`` at construction
55
+ ``http`` rejected yes ``None`` built HTTP
56
+ ``http`` rejected no — — ``ValueError`` at construction
57
+ =========== ========== ====== ============== =========== ==================================
58
+
59
+ "Can deliver" is judged once per ``emit``, so an application that initialises the SDK after
60
+ building this sink starts using it without rebuilding one. An argument whose only consumer is
61
+ a backend this construction will never select is a ``ValueError`` rather than a silent
62
+ ignore — ``opener`` where no HTTP fallback is built, ``client`` under ``"http"``. That is the
63
+ defect this rule comes from: ``opener`` used to be accepted and then ignored whenever the SDK
64
+ imported. ``max_retries`` is deliberately outside the rule, since its default cannot be told
65
+ from an explicit pass of the same value.
30
66
 
31
67
  Attributes:
68
+ client: The SDK object this sink captures through, or ``None`` when the HTTP fallback is
69
+ the only backend it can select.
32
70
  sent: Events captured or sent to Sentry.
33
71
  skipped: Events below the minimum level, or without a usable level, that were not sent.
34
72
  transport_errors: Events whose send raised something other than an already-counted
@@ -48,16 +86,29 @@ class SentrySink:
48
86
  dsn: str | None = None,
49
87
  *,
50
88
  min_level: str = "ERROR",
89
+ backend: Backend = "auto",
51
90
  client: Any = None,
52
91
  opener: Any = None,
53
92
  max_retries: int = 3,
54
93
  ) -> None:
55
94
  """Selects the SDK or the HTTP-envelope fallback and sets the level floor.
56
95
 
96
+ The order is deliberate: an unknown ``backend`` is rejected first, then the arguments the
97
+ selection cannot use, then the selections nothing can build. Several constructions trip a
98
+ conflict *and* a refusal — any of them with no DSN, for instance — and each raises
99
+ ``ValueError`` either way, so the conflict is reported first because it names an argument
100
+ the caller can drop, where the refusal only says the selection cannot be built.
101
+
102
+ Both backends this construction can select are built here rather than on first use. A
103
+ fallback built lazily would miss the worker's one-shot ``log_foundry_stop_signal`` offer
104
+ and stop being interruptible (SPEC-027), and it would rebind transport state inside
105
+ ``emit``, contradicting this class's SPEC-028 exemption.
106
+
57
107
  Args:
58
108
  dsn: The Sentry DSN. It is required for the fallback, which needs it to know where to
59
109
  POST.
60
110
  min_level: The lowest level worth sending.
111
+ backend: Which transport to use — ``"auto"``, ``"sdk"`` or ``"http"``.
61
112
  client: A ``sentry_sdk``-shaped object to use instead of importing one.
62
113
  opener: A ``urlopen``-shaped callable for the fallback, for tests.
63
114
  max_retries: Retries the fallback's HTTP transport makes.
@@ -66,22 +117,51 @@ class SentrySink:
66
117
  None.
67
118
 
68
119
  Raises:
69
- ValueError: If no SDK is available and no DSN was given.
120
+ ValueError: If ``backend`` is not one of the three names; if an argument cannot be used
121
+ by any backend this construction can select; or if the selection cannot be built —
122
+ ``"sdk"`` with no client available, ``"http"`` with no DSN, or the default with
123
+ neither.
70
124
  """
125
+ if backend not in _BACKENDS:
126
+ raise ValueError(f"SentrySink backend must be one of {_BACKENDS!r}, not {backend!r}")
127
+ if client is not None and backend == "http":
128
+ raise ValueError(
129
+ "SentrySink(backend='http') never captures through a client; drop client= or "
130
+ "select a backend that can use it"
131
+ )
132
+ if opener is not None and (backend == "sdk" or dsn is None):
133
+ remedy = "drop backend='sdk'" if backend == "sdk" else "pass a dsn"
134
+ raise ValueError(
135
+ "SentrySink builds no HTTP fallback for this construction, so opener= would "
136
+ f"never be called; {remedy}"
137
+ )
71
138
  self._dsn = dsn
139
+ self._backend = backend
72
140
  self._min_rank = _LEVEL_RANK.get(min_level.upper(), _LEVEL_RANK["ERROR"])
73
141
  self.sent = 0
74
142
  self.skipped = 0
75
143
  self.transport_errors = 0
76
144
  self._counter_lock = threading.Lock()
77
- self.client = client if client is not None else _import_sdk()
145
+ self.client: Any = None if backend == "http" else (
146
+ client if client is not None else _import_sdk()
147
+ )
78
148
  self._http: HTTPSink | None = None
79
149
  self._auth_header = ""
80
- if self.client is None:
81
- if dsn is None:
150
+ if backend == "sdk":
151
+ if self.client is None:
152
+ raise ValueError(
153
+ "SentrySink(backend='sdk') requires the sentry extra or an injected client="
154
+ )
155
+ elif dsn is None:
156
+ if backend == "http":
157
+ raise ValueError(
158
+ "SentrySink(backend='http') requires a dsn for the HTTP-envelope fallback"
159
+ )
160
+ if self.client is None:
82
161
  raise ValueError(
83
162
  "SentrySink without sentry-sdk requires a dsn for the HTTP-envelope fallback"
84
163
  )
164
+ else:
85
165
  ingest_url, self._auth_header = _parse_dsn(dsn)
86
166
  self._http = HTTPSink(ingest_url, opener=opener, max_retries=max_retries)
87
167
  self._stop_signal: threading.Event | None = None
@@ -94,6 +174,9 @@ class SentrySink:
94
174
  FR-001). Letting the first failure propagate would hand the worker a batch whose earlier
95
175
  events Sentry had already accepted, and the retry would duplicate them.
96
176
 
177
+ The backend is chosen once, before the loop, so one batch cannot split across transports
178
+ partway through and so the client is probed once rather than per event.
179
+
97
180
  Args:
98
181
  batch: The events to consider.
99
182
 
@@ -101,10 +184,12 @@ class SentrySink:
101
184
  None.
102
185
 
103
186
  Raises:
104
- SinkDeliveryError: If every qualifying event failed to land. An event below the
105
- minimum level is skipped rather than lost, so a batch of nothing but skipped events
106
- is a successful emit — there was never anything to deliver.
187
+ SinkDeliveryError: If every qualifying event failed to land, which includes the case
188
+ where no backend can deliver at all (SPEC-043 FR-003). An event below the minimum
189
+ level is skipped rather than lost, so a batch of nothing but skipped events is a
190
+ successful emit — there was never anything to deliver.
107
191
  """
192
+ backend = self._select_backend()
108
193
  attempted = delivered = 0
109
194
  for event in batch:
110
195
  if not self._qualifies(event):
@@ -112,7 +197,7 @@ class SentrySink:
112
197
  self.skipped += 1
113
198
  continue
114
199
  attempted += 1
115
- if not self._capture(event):
200
+ if not self._capture(event, backend):
116
201
  continue
117
202
  with self._counter_lock:
118
203
  self.sent += 1
@@ -133,7 +218,12 @@ class SentrySink:
133
218
  interpreter exit got to it.
134
219
 
135
220
  Only the injected-or-imported SDK client has a queue. The ``urllib`` fallback posts an
136
- envelope per event and holds nothing, so with no client this is correctly a no-op.
221
+ envelope per event and holds nothing, so with no client this is correctly a no-op — and
222
+ ``backend="http"`` holds none, which is what keeps this from pushing an application's own
223
+ Sentry transport on behalf of a sink that never captures through it.
224
+
225
+ A client the per-emit predicate currently reads as unable to deliver is still flushed: it
226
+ may become usable before the next batch, and flushing one that cannot is a no-op anyway.
137
227
 
138
228
  ``Client.flush`` is probed by name, as every optional member the library calls on an object
139
229
  it does not own is: a stand-in ``client=`` satisfying only ``capture_event`` stays valid,
@@ -254,8 +344,20 @@ class SentrySink:
254
344
  with self._counter_lock:
255
345
  return SinkLosses(dropped=0, failed=self.failed + self.transport_errors)
256
346
 
257
- def _capture(self, event: dict[str, object]) -> bool:
258
- """Sends one event by whichever transport is configured.
347
+ def _capture(self, event: dict[str, object], backend: _Selected | None) -> bool:
348
+ """Sends one event by the backend ``emit`` resolved for this batch.
349
+
350
+ A ``None`` backend is refused here, before the ``try``, and moves nothing. Letting it fall
351
+ into the envelope branch instead would reach ``_post_envelope``'s assertion, whose
352
+ ``AssertionError`` the guard below counts as a ``transport_errors`` and announces through
353
+ ``_diag`` — both forbidden for a refusal (SPEC-043 FR-003), because the caller is told by
354
+ the ``SinkDeliveryError`` ``emit`` raises and counting it here would report one loss twice.
355
+ The refusal stays inside the per-event loop so the level filter keeps running ahead of it
356
+ and the raise names the qualifying count.
357
+
358
+ The branch is on the resolved name rather than on ``self.client is not None``: that
359
+ condition is still true under ``"auto"`` when the client cannot deliver, so it would take
360
+ the SDK branch anyway and preserve the very defect this selection exists to fix.
259
361
 
260
362
  One guard covers both branches, catching ``Exception`` rather than an enumerated set.
261
363
  Anything escaping here propagates mid-batch and hands the worker a batch Sentry has
@@ -267,6 +369,7 @@ class SentrySink:
267
369
 
268
370
  Args:
269
371
  event: The event to send.
372
+ backend: The backend ``emit`` resolved, or ``None`` when nothing can deliver.
270
373
 
271
374
  Returns:
272
375
  True when it landed, False when it did not.
@@ -276,8 +379,10 @@ class SentrySink:
276
379
  has already counted and announced it, and counting it again would double-report.
277
380
  Only the exception type is ever written (arch §6).
278
381
  """
382
+ if backend is None:
383
+ return False
279
384
  try:
280
- if self.client is not None:
385
+ if backend == "sdk":
281
386
  self.client.capture_event(self._sentry_event(event))
282
387
  else:
283
388
  self._post_envelope(event)
@@ -290,6 +395,76 @@ class SentrySink:
290
395
  return False
291
396
  return True
292
397
 
398
+ def _select_backend(self) -> _Selected | None:
399
+ """Picks the transport for one batch, or ``None`` when nothing can deliver.
400
+
401
+ An explicit selection is honoured rather than substituted (SPEC-043 FR-002): ``"sdk"``
402
+ against a client that cannot deliver returns ``None`` so the batch is refused, because a
403
+ caller who named a backend and got a different one is this sink's original defect in a
404
+ new place. ``"http"`` never consults the client, which is what keeps a held-but-unusable
405
+ SDK out of the decision entirely.
406
+
407
+ Args:
408
+ None.
409
+
410
+ Returns:
411
+ ``"sdk"``, ``"http"``, or ``None`` when neither backend can deliver.
412
+
413
+ Raises:
414
+ None.
415
+ """
416
+ if self._backend == "http":
417
+ return "http"
418
+ if self.client is not None and self._client_can_deliver():
419
+ return "sdk"
420
+ if self._backend == "sdk":
421
+ return None
422
+ return "http" if self._http is not None else None
423
+
424
+ def _client_can_deliver(self) -> bool:
425
+ """Reports whether the held client has somewhere to send, not merely that it exists.
426
+
427
+ Three states cannot deliver and only one of them reports itself inactive: an
428
+ uninitialised process holds a ``NonRecordingClient`` (``is_active()`` false), while
429
+ ``init()`` without a DSN and a client that has been ``close()``d both report themselves
430
+ active with a ``None`` transport and drop every event silently. So the predicate takes
431
+ both members, and it is the transport that binds — ``is_active()`` is a hardcoded class
432
+ discriminator, kept because it is the SDK's documented answer and a future client may
433
+ diverge, not because anything here can distinguish it from the transport alone.
434
+
435
+ It descends first: ``__init__`` holds the ``sentry_sdk`` **module**, which publishes
436
+ neither member, so a probe that read the held object would call the defective path usable
437
+ and leave the defect in place. ``is_active`` is *called* rather than read, since a bound
438
+ method is truthy and reading one yields a guard that can never fail; a non-callable
439
+ ``is_active`` is treated as absent instead. Absence means usable throughout, which is what
440
+ keeps an injected double, a pre-SPEC-043 client and a pre-2.0 SDK working — hence the
441
+ sentinel, since an absent ``transport`` and a ``None`` one are opposite answers.
442
+
443
+ Args:
444
+ None.
445
+
446
+ Returns:
447
+ True when the client is worth capturing through.
448
+
449
+ Raises:
450
+ None. A probe may never be the reason a batch fails (SPEC-025), so a client that raises
451
+ while being questioned is treated as usable and the fault is announced by type.
452
+ """
453
+ try:
454
+ target = self.client
455
+ descend = getattr(target, "get_client", None)
456
+ if callable(descend):
457
+ descended = descend()
458
+ if descended is not None:
459
+ target = descended
460
+ is_active = getattr(target, "is_active", None)
461
+ if callable(is_active) and not is_active():
462
+ return False
463
+ return getattr(target, "transport", _ABSENT) is not None
464
+ except Exception as err:
465
+ _diag.absorbed("probing the Sentry client", err, "it is treated as usable")
466
+ return True
467
+
293
468
  def _qualifies(self, event: dict[str, object]) -> bool:
294
469
  """Reports whether an event is at or above the configured level floor.
295
470
 
@@ -334,7 +509,9 @@ class SentrySink:
334
509
  """POSTs one event as a Sentry envelope over the HTTP fallback.
335
510
 
336
511
  The assertion narrows the type for mypy rather than checking at runtime: the HTTP
337
- transport is set in the constructor whenever the path that reaches here is in use.
512
+ transport is set in the constructor whenever the path that reaches here is in use, which
513
+ is true because ``_capture`` refuses a ``None`` backend before reaching this branch and
514
+ ``_select_backend`` only answers ``"http"`` where one was built.
338
515
 
339
516
  Args:
340
517
  event: The event to send.