objectstore-client 0.2.3__tar.gz → 0.2.5__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.3
2
2
  Name: objectstore-client
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Summary: Client SDK for Objectstore, the Sentry object storage platform
5
5
  Author: Sentry
6
6
  Author-email: Sentry <oss@sentry.io>
@@ -109,7 +109,7 @@ License: # Functional Source License, Version 1.1, Apache 2.0 Future License
109
109
  under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
110
110
  CONDITIONS OF ANY KIND, either express or implied. See the License for the
111
111
  specific language governing permissions and limitations under the License.
112
- Requires-Dist: sentry-sdk>=2.42.1
112
+ Requires-Dist: sentry-sdk>=2.66.0
113
113
  Requires-Dist: urllib3>=2.2.2
114
114
  Requires-Dist: zstandard>=0.18.0
115
115
  Requires-Dist: filetype>=1.2.0
@@ -375,6 +375,24 @@ attachments = Usecase("attachments")
375
375
 
376
376
  See the docstrings on `Client`, `Usecase`, and `Session` for full parameter documentation.
377
377
 
378
+ ### Tracing
379
+
380
+ When the Sentry SDK is initialized, every method that talks to Objectstore
381
+ automatically emits a span, with no setup required on your side. If you also
382
+ want a span for the underlying HTTP request, Sentry's `StdlibIntegration`
383
+ covers that, it's enabled by default:
384
+
385
+ ```python
386
+ import sentry_sdk
387
+
388
+ sentry_sdk.init(
389
+ dsn="...",
390
+ traces_sample_rate=1.0,
391
+ # Optional: controls which hosts get `sentry-trace`/`baggage` headers.
392
+ # trace_propagation_targets=["http://objectstore"],
393
+ )
394
+ ```
395
+
378
396
  ## Development
379
397
 
380
398
  ### Environment Setup
@@ -256,6 +256,24 @@ attachments = Usecase("attachments")
256
256
 
257
257
  See the docstrings on `Client`, `Usecase`, and `Session` for full parameter documentation.
258
258
 
259
+ ### Tracing
260
+
261
+ When the Sentry SDK is initialized, every method that talks to Objectstore
262
+ automatically emits a span, with no setup required on your side. If you also
263
+ want a span for the underlying HTTP request, Sentry's `StdlibIntegration`
264
+ covers that, it's enabled by default:
265
+
266
+ ```python
267
+ import sentry_sdk
268
+
269
+ sentry_sdk.init(
270
+ dsn="...",
271
+ traces_sample_rate=1.0,
272
+ # Optional: controls which hosts get `sentry-trace`/`baggage` headers.
273
+ # trace_propagation_targets=["http://objectstore"],
274
+ )
275
+ ```
276
+
259
277
  ## Development
260
278
 
261
279
  ### Environment Setup
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "objectstore-client"
3
- version = "0.2.3"
3
+ version = "0.2.5"
4
4
  description = "Client SDK for Objectstore, the Sentry object storage platform"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Sentry", email = "oss@sentry.io" }]
7
7
  license = { file = "LICENSE.md" }
8
8
  requires-python = ">=3.11"
9
9
  dependencies = [
10
- "sentry-sdk>=2.42.1",
10
+ "sentry-sdk>=2.66.0",
11
11
  "urllib3>=2.2.2",
12
12
  "zstandard>=0.18.0",
13
13
  "filetype>=1.2.0",
@@ -5,6 +5,7 @@ import warnings
5
5
  from collections.abc import Mapping, Sequence
6
6
  from dataclasses import asdict, dataclass
7
7
  from datetime import UTC, datetime, timedelta
8
+ from importlib.metadata import version
8
9
  from io import BytesIO
9
10
  from typing import IO, Any, Literal, NamedTuple, cast
10
11
  from urllib.parse import urlparse
@@ -34,10 +35,14 @@ from objectstore_client.metrics import (
34
35
  )
35
36
  from objectstore_client.multipart import MultipartUpload
36
37
  from objectstore_client.scope import Scope
38
+ from objectstore_client.tracing import storage_span
37
39
 
38
40
  # Query parameter carrying a JWT, mirroring the `x-os-auth` header.
39
41
  PARAM_AUTH = "os_auth"
40
42
 
43
+ # Identifies this library to the server, mirroring the Rust client's user agent.
44
+ USER_AGENT = f"objectstore-client/{version('objectstore-client')}"
45
+
41
46
 
42
47
  class GetResponse(NamedTuple):
43
48
  metadata: Metadata
@@ -100,8 +105,13 @@ class Client:
100
105
  "http://objectstore:8888"). metrics_backend: Optional metrics backend for
101
106
  tracking storage operations. Defaults to ``NoOpMetricsBackend`` if not
102
107
  provided.
103
- propagate_traces: Whether to propagate Sentry trace headers in requests to
104
- objectstore. Defaults to ``False``.
108
+ propagate_traces: **Deprecated.** Use Sentry's ``StdlibIntegration``
109
+ instead; it propagates traces on the underlying HTTP request
110
+ regardless of this flag, unless the host application's Sentry SDK
111
+ is configured to opt out.
112
+
113
+ This only controls headers this client adds itself. Defaults to
114
+ ``False``.
105
115
  retries: Number of connection retries for failed requests.
106
116
  Defaults to ``3`` if not specified. **Note:** only connection failures are
107
117
  retried, not read failures (as compression streams cannot be rewound).
@@ -122,7 +132,9 @@ class Client:
122
132
 
123
133
  connection_kwargs: Additional keyword arguments to pass to the underlying
124
134
  urllib3 connection pool (e.g., custom headers, SSL settings, advanced
125
- timeouts).
135
+ timeouts). By default, requests carry a ``User-Agent`` header of
136
+ ``objectstore-client/{version}``; pass a ``headers`` mapping with a
137
+ ``User-Agent`` key here to override it.
126
138
  token: A ``SecretKey`` that signs a fresh JWT for each request
127
139
  using an EdDSA keypair, or a static pre-signed JWT string used
128
140
  as-is for every request. Use a ``SecretKey`` for internal
@@ -140,6 +152,14 @@ class Client:
140
152
  connection_kwargs: Mapping[str, Any] | None = None,
141
153
  token: TokenProvider | None = None,
142
154
  ):
155
+ if propagate_traces:
156
+ warnings.warn(
157
+ "`propagate_traces` is deprecated; Sentry's `StdlibIntegration` "
158
+ "already propagates traces on the underlying HTTP request",
159
+ DeprecationWarning,
160
+ stacklevel=2,
161
+ )
162
+
143
163
  connection_kwargs_to_use = asdict(_ConnectionDefaults())
144
164
 
145
165
  if retries:
@@ -158,6 +178,10 @@ class Client:
158
178
  if connection_kwargs:
159
179
  connection_kwargs_to_use = {**connection_kwargs_to_use, **connection_kwargs}
160
180
 
181
+ headers = urllib3.HTTPHeaderDict({"User-Agent": USER_AGENT})
182
+ headers.update(connection_kwargs_to_use.get("headers") or {})
183
+ connection_kwargs_to_use["headers"] = headers
184
+
161
185
  self._pool = urllib3.connectionpool.connection_from_url(
162
186
  base_url, **connection_kwargs_to_use
163
187
  )
@@ -248,12 +272,29 @@ class Session:
248
272
  """
249
273
  if not isinstance(self._token, SecretKey):
250
274
  raise ValueError("no secret key configured on this session")
251
- return self._token.token_for_scope(
252
- self._usecase.name,
253
- self._scope,
254
- permissions,
255
- expiry_seconds,
275
+
276
+ resolved_expiry = (
277
+ expiry_seconds if expiry_seconds is not None else self._token.expiry_seconds
256
278
  )
279
+ resolved_permissions = (
280
+ permissions if permissions is not None else self._token.permissions
281
+ )
282
+ with storage_span(
283
+ "mint_token",
284
+ self._usecase,
285
+ self._scope,
286
+ expiry_seconds=resolved_expiry,
287
+ ) as span:
288
+ token = self._token.token_for_scope(
289
+ self._usecase.name,
290
+ self._scope,
291
+ permissions,
292
+ expiry_seconds,
293
+ )
294
+ span.set_attribute(
295
+ "objectstore.permissions", [str(p) for p in resolved_permissions]
296
+ )
297
+ return token
257
298
 
258
299
  def _auth_token(self) -> str | None:
259
300
  """Returns a token for internal auth headers."""
@@ -391,9 +432,12 @@ class Session:
391
432
  if key == "":
392
433
  key = None
393
434
 
394
- with measure_storage_operation(
395
- self._metrics_backend, "put", self._usecase.name
396
- ) as metric_emitter:
435
+ with (
436
+ storage_span("put", self._usecase, self._scope) as span,
437
+ measure_storage_operation(
438
+ self._metrics_backend, "put", self._usecase.name
439
+ ) as metrics,
440
+ ):
397
441
  retries = None # by default use the pool's value, set by the Client
398
442
  if compress_with != "none":
399
443
  # For on-the-fly compression, don't attempt read retries,
@@ -419,9 +463,21 @@ class Session:
419
463
  # Must do this after streaming `body` as that's what is responsible
420
464
  # for advancing the seek position in both streams
421
465
  if precompressed is None:
422
- metric_emitter.record_uncompressed_size(original_body.tell())
466
+ metrics.record_uncompressed_size(original_body.tell())
423
467
  if encoding != "none":
424
- metric_emitter.record_compressed_size(body.tell(), encoding)
468
+ metrics.record_compressed_size(body.tell(), encoding)
469
+
470
+ # Set after the response, since the key may be server-generated.
471
+ span.set_attribute("objectstore.key", res["key"])
472
+ span.set_attribute("objectstore.compression", encoding)
473
+ if metrics.uncompressed_size is not None:
474
+ span.set_attribute(
475
+ "objectstore.uncompressed_size", metrics.uncompressed_size
476
+ )
477
+ if metrics.compressed_size is not None:
478
+ span.set_attribute(
479
+ "objectstore.compressed_size", metrics.compressed_size
480
+ )
425
481
  return res["key"]
426
482
 
427
483
  def get(
@@ -446,38 +502,49 @@ class Session:
446
502
  """
447
503
 
448
504
  headers = self._make_headers()
449
- with measure_storage_operation(
450
- self._metrics_backend, "get", self._usecase.name
451
- ):
452
- response = self._pool.request(
453
- "GET",
454
- self._make_url(key),
455
- preload_content=False,
456
- decode_content=False,
457
- headers=headers,
458
- )
459
- if response.status == 404:
460
- response.read() # drain body so urllib3 returns the connection
461
- return None
462
- raise_for_status(response)
463
- # OR: should I use `response.stream()`?
464
- stream = cast(IO[bytes], response)
465
- metadata = Metadata.from_headers(response.headers)
466
-
467
- encoding_accepted = accept_encoding is not None and (
468
- "*" in accept_encoding or metadata.compression in accept_encoding
469
- )
470
- if metadata.compression and decompress and not encoding_accepted:
471
- if metadata.compression != "zstd":
472
- raise NotImplementedError(
473
- "Transparent decoding of anything but `zstd` is not implemented yet"
505
+ with storage_span("get", self._usecase, self._scope, key=key) as span:
506
+ with measure_storage_operation(
507
+ self._metrics_backend, "get", self._usecase.name
508
+ ):
509
+ response = self._pool.request(
510
+ "GET",
511
+ self._make_url(key),
512
+ preload_content=False,
513
+ decode_content=False,
514
+ headers=headers,
474
515
  )
516
+ if response.status == 404:
517
+ response.read() # drain body so urllib3 returns the connection
518
+ span.set_attribute("objectstore.found", False)
519
+ return None
520
+ raise_for_status(response)
521
+ span.set_attribute("objectstore.found", True)
522
+
523
+ # OR: should I use `response.stream()`?
524
+ stream = cast(IO[bytes], response)
525
+ metadata = Metadata.from_headers(response.headers)
526
+ span.set_attribute(
527
+ "objectstore.compression", metadata.compression or "none"
528
+ )
475
529
 
476
- metadata.compression = None
477
- dctx = zstandard.ZstdDecompressor()
478
- stream = dctx.stream_reader(stream, read_across_frames=True)
479
-
480
- return GetResponse(metadata, stream)
530
+ encoding_accepted = accept_encoding is not None and (
531
+ "*" in accept_encoding or metadata.compression in accept_encoding
532
+ )
533
+ decompressed = False
534
+ if metadata.compression and decompress and not encoding_accepted:
535
+ if metadata.compression != "zstd":
536
+ raise NotImplementedError(
537
+ "Transparent decoding of anything but `zstd` is not "
538
+ "implemented yet"
539
+ )
540
+
541
+ metadata.compression = None
542
+ dctx = zstandard.ZstdDecompressor()
543
+ stream = dctx.stream_reader(stream, read_across_frames=True)
544
+ decompressed = True
545
+
546
+ span.set_attribute("objectstore.decompressed", decompressed)
547
+ return GetResponse(metadata, stream)
481
548
 
482
549
  def object_url(self, key: str, token_validity: timedelta | None = None) -> str:
483
550
  """
@@ -548,22 +615,32 @@ class Session:
548
615
  )
549
616
  duration_secs = math.ceil(duration.total_seconds())
550
617
 
551
- # `_make_url` already percent-encodes the path identically to the wire.
552
- encoded_path = self._make_url(key)
553
- timestamp = datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
554
- encoded_query = utils.encode_query(
555
- f"{presign.PARAM_KID}={self._token.kid}"
556
- f"&{presign.PARAM_TIMESTAMP}={timestamp}"
557
- f"&{presign.PARAM_DURATION}={duration_secs}"
558
- )
618
+ with storage_span(
619
+ "presign",
620
+ self._usecase,
621
+ self._scope,
622
+ key=key,
623
+ method=method,
624
+ duration_seconds=duration_secs,
625
+ ):
626
+ # `_make_url` already percent-encodes the path identically to the wire.
627
+ encoded_path = self._make_url(key)
628
+ timestamp = datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
629
+ encoded_query = utils.encode_query(
630
+ f"{presign.PARAM_KID}={self._token.kid}"
631
+ f"&{presign.PARAM_TIMESTAMP}={timestamp}"
632
+ f"&{presign.PARAM_DURATION}={duration_secs}"
633
+ )
559
634
 
560
- canonical = presign.build_canonical_form(method, encoded_path, encoded_query)
561
- signature = self._token.signature_for_canonical_form(canonical)
635
+ canonical = presign.build_canonical_form(
636
+ method, encoded_path, encoded_query
637
+ )
638
+ signature = self._token.signature_for_canonical_form(canonical)
562
639
 
563
- return (
564
- f"{self._base_url()}"
565
- f"{encoded_path}?{encoded_query}&{presign.PARAM_SIG}={signature}"
566
- )
640
+ return (
641
+ f"{self._base_url()}"
642
+ f"{encoded_path}?{encoded_query}&{presign.PARAM_SIG}={signature}"
643
+ )
567
644
 
568
645
  def head(self, key: str) -> Metadata | None:
569
646
  """
@@ -575,8 +652,11 @@ class Session:
575
652
  and therefore bumps its expiration.
576
653
  """
577
654
  headers = self._make_headers()
578
- with measure_storage_operation(
579
- self._metrics_backend, "head", self._usecase.name
655
+ with (
656
+ storage_span("head", self._usecase, self._scope, key=key) as span,
657
+ measure_storage_operation(
658
+ self._metrics_backend, "head", self._usecase.name
659
+ ),
580
660
  ):
581
661
  response = self._pool.request(
582
662
  "HEAD",
@@ -585,8 +665,10 @@ class Session:
585
665
  preload_content=True,
586
666
  )
587
667
  if response.status == 404:
668
+ span.set_attribute("objectstore.found", False)
588
669
  return None
589
670
  raise_for_status(response)
671
+ span.set_attribute("objectstore.found", True)
590
672
  return Metadata.from_headers(response.headers)
591
673
 
592
674
  def delete(self, key: str) -> None:
@@ -595,8 +677,11 @@ class Session:
595
677
  """
596
678
 
597
679
  headers = self._make_headers()
598
- with measure_storage_operation(
599
- self._metrics_backend, "delete", self._usecase.name
680
+ with (
681
+ storage_span("delete", self._usecase, self._scope, key=key),
682
+ measure_storage_operation(
683
+ self._metrics_backend, "delete", self._usecase.name
684
+ ),
600
685
  ):
601
686
  response = self._pool.request(
602
687
  "DELETE",
@@ -657,8 +742,13 @@ class Session:
657
742
  if key == "":
658
743
  key = None
659
744
 
660
- with measure_storage_operation(
661
- self._metrics_backend, "multipart.initiate", self._usecase.name
745
+ with (
746
+ storage_span(
747
+ "multipart.initiate", self._usecase, self._scope, key=key
748
+ ) as span,
749
+ measure_storage_operation(
750
+ self._metrics_backend, "multipart.initiate", self._usecase.name
751
+ ),
662
752
  ):
663
753
  response = self._pool.request(
664
754
  "POST" if not key else "PUT",
@@ -669,6 +759,9 @@ class Session:
669
759
  )
670
760
  raise_for_status(response)
671
761
  res = response.json()
762
+ # Set after the response, since the key may be server-generated.
763
+ span.set_attribute("objectstore.key", res["key"])
764
+ span.set_attribute("objectstore.upload_id", res["upload_id"])
672
765
  return MultipartUpload(self, res["key"], res["upload_id"])
673
766
 
674
767
  def resume_multipart_upload(self, key: str, upload_id: str) -> MultipartUpload:
@@ -11,6 +11,7 @@ from urllib.parse import urlencode
11
11
 
12
12
  from objectstore_client.errors import RequestError, raise_for_status
13
13
  from objectstore_client.metrics import measure_storage_operation
14
+ from objectstore_client.tracing import storage_span
14
15
 
15
16
  if TYPE_CHECKING:
16
17
  from objectstore_client.client import Session
@@ -118,11 +119,22 @@ class MultipartUpload:
118
119
  )
119
120
  url = self._session._make_multipart_url("parts", self._key, query)
120
121
 
121
- with measure_storage_operation(
122
- self._session._metrics_backend,
123
- "multipart.put_part",
124
- self._session._usecase.name,
125
- ) as metric_emitter:
122
+ with (
123
+ storage_span(
124
+ "multipart.put_part",
125
+ self._session._usecase,
126
+ self._session._scope,
127
+ key=self._key,
128
+ upload_id=self._upload_id,
129
+ part_number=part_number,
130
+ size=content_length,
131
+ ),
132
+ measure_storage_operation(
133
+ self._session._metrics_backend,
134
+ "multipart.put_part",
135
+ self._session._usecase.name,
136
+ ) as metric_emitter,
137
+ ):
126
138
  response = self._session._pool.request(
127
139
  "PUT",
128
140
  url,
@@ -141,57 +153,73 @@ class MultipartUpload:
141
153
  all_parts: list[PartInfo] = []
142
154
  marker: int | None = None
143
155
 
144
- while True:
145
- params: dict[str, str] = {"upload_id": self._upload_id}
146
- if marker is not None:
147
- params["part_number_marker"] = str(marker)
156
+ with storage_span(
157
+ "multipart.list_parts",
158
+ self._session._usecase,
159
+ self._session._scope,
160
+ key=self._key,
161
+ upload_id=self._upload_id,
162
+ ) as span:
163
+ while True:
164
+ params: dict[str, str] = {"upload_id": self._upload_id}
165
+ if marker is not None:
166
+ params["part_number_marker"] = str(marker)
167
+
168
+ query = urlencode(params)
169
+ url = self._session._make_multipart_url("parts", self._key, query)
170
+ headers = self._session._make_headers()
171
+
172
+ response = self._session._pool.request(
173
+ "GET",
174
+ url,
175
+ headers=headers,
176
+ preload_content=True,
177
+ )
178
+ raise_for_status(response)
179
+ data = response.json()
180
+
181
+ for p in data["parts"]:
182
+ all_parts.append(
183
+ PartInfo(
184
+ part_number=p["part_number"],
185
+ etag=p["etag"],
186
+ last_modified=datetime.fromisoformat(p["last_modified"]),
187
+ size=p["size"],
188
+ )
189
+ )
190
+
191
+ if not data["is_truncated"]:
192
+ span.set_attribute("objectstore.part_count", len(all_parts))
193
+ return all_parts
194
+
195
+ marker = data.get("next_part_number_marker")
196
+ if marker is None:
197
+ raise RequestError(
198
+ "Server returned is_truncated=true but no "
199
+ "next_part_number_marker",
200
+ status=200,
201
+ response=str(data),
202
+ )
148
203
 
149
- query = urlencode(params)
150
- url = self._session._make_multipart_url("parts", self._key, query)
204
+ def abort(self) -> None:
205
+ """Aborts this multipart upload, cleaning up server-side state."""
206
+ with storage_span(
207
+ "multipart.abort",
208
+ self._session._usecase,
209
+ self._session._scope,
210
+ key=self._key,
211
+ upload_id=self._upload_id,
212
+ ):
213
+ query = urlencode({"upload_id": self._upload_id})
214
+ url = self._session._make_multipart_url(None, self._key, query)
151
215
  headers = self._session._make_headers()
152
216
 
153
217
  response = self._session._pool.request(
154
- "GET",
218
+ "DELETE",
155
219
  url,
156
220
  headers=headers,
157
- preload_content=True,
158
221
  )
159
222
  raise_for_status(response)
160
- data = response.json()
161
-
162
- for p in data["parts"]:
163
- all_parts.append(
164
- PartInfo(
165
- part_number=p["part_number"],
166
- etag=p["etag"],
167
- last_modified=datetime.fromisoformat(p["last_modified"]),
168
- size=p["size"],
169
- )
170
- )
171
-
172
- if not data["is_truncated"]:
173
- return all_parts
174
-
175
- marker = data.get("next_part_number_marker")
176
- if marker is None:
177
- raise RequestError(
178
- "Server returned is_truncated=true but no next_part_number_marker",
179
- status=200,
180
- response=str(data),
181
- )
182
-
183
- def abort(self) -> None:
184
- """Aborts this multipart upload, cleaning up server-side state."""
185
- query = urlencode({"upload_id": self._upload_id})
186
- url = self._session._make_multipart_url(None, self._key, query)
187
- headers = self._session._make_headers()
188
-
189
- response = self._session._pool.request(
190
- "DELETE",
191
- url,
192
- headers=headers,
193
- )
194
- raise_for_status(response)
195
223
 
196
224
  def complete(self, parts: Sequence[CompletePart | PartInfo]) -> str:
197
225
  """Completes the multipart upload, assembling all parts into the final object.
@@ -216,10 +244,20 @@ class MultipartUpload:
216
244
  }
217
245
  ).encode("utf-8")
218
246
 
219
- with measure_storage_operation(
220
- self._session._metrics_backend,
221
- "multipart.complete",
222
- self._session._usecase.name,
247
+ with (
248
+ storage_span(
249
+ "multipart.complete",
250
+ self._session._usecase,
251
+ self._session._scope,
252
+ key=self._key,
253
+ upload_id=self._upload_id,
254
+ part_count=len(sorted_parts),
255
+ ),
256
+ measure_storage_operation(
257
+ self._session._metrics_backend,
258
+ "multipart.complete",
259
+ self._session._usecase.name,
260
+ ),
223
261
  ):
224
262
  response = self._session._pool.request(
225
263
  "POST",
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Generator
4
+ from contextlib import contextmanager
5
+ from typing import TYPE_CHECKING
6
+
7
+ import sentry_sdk
8
+ from sentry_sdk.traces import StreamedSpan
9
+
10
+ from objectstore_client.scope import Scope
11
+
12
+ if TYPE_CHECKING:
13
+ from sentry_sdk._types import Attributes
14
+
15
+ from objectstore_client.client import Usecase
16
+
17
+ # Origin recorded on every span this module creates, per Sentry's span-origin
18
+ # convention (`auto.<integration>.<library>`).
19
+ SPAN_ORIGIN = "auto.objectstore.client"
20
+
21
+ SpanAttributeValue = str | int | float | bool | list[str] | list[int] | list[float]
22
+
23
+
24
+ @contextmanager
25
+ def storage_span(
26
+ operation: str,
27
+ usecase: Usecase,
28
+ scope: Scope,
29
+ **attributes: SpanAttributeValue | None,
30
+ ) -> Generator[StreamedSpan]:
31
+ """
32
+ Starts a span for a single Objectstore operation.
33
+
34
+ ``operation`` is the same literal already passed to
35
+ ``measure_storage_operation``, so the span's ``op``
36
+ (``objectstore.<operation>``), its name, and the ``storage.<operation>.*``
37
+ metrics all derive from one source. Keyword arguments become
38
+ ``objectstore.<key>`` span attributes, with ``None`` values dropped so
39
+ call sites can pass optionals inline; attributes that are only known after
40
+ the request completes can be set on the yielded span instead.
41
+ """
42
+ op = f"objectstore.{operation}"
43
+ name = f"{op} {usecase.name}"
44
+ span_attributes: Attributes = {
45
+ "sentry.op": op,
46
+ "sentry.origin": SPAN_ORIGIN,
47
+ "objectstore.usecase": usecase.name,
48
+ }
49
+ for scope_key, scope_value in scope.dict().items():
50
+ span_attributes[f"objectstore.scopes.{scope_key}"] = scope_value
51
+ for attribute_key, attribute_value in attributes.items():
52
+ if attribute_value is not None:
53
+ span_attributes[f"objectstore.{attribute_key}"] = attribute_value
54
+
55
+ with sentry_sdk.traces.start_span(name=name, attributes=span_attributes) as span:
56
+ yield span