objectstore-client 0.2.4__tar.gz → 0.2.6__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.4
3
+ Version: 0.2.6
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.4"
3
+ version = "0.2.6"
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",
@@ -35,6 +35,7 @@ from objectstore_client.metrics import (
35
35
  )
36
36
  from objectstore_client.multipart import MultipartUpload
37
37
  from objectstore_client.scope import Scope
38
+ from objectstore_client.tracing import storage_span
38
39
 
39
40
  # Query parameter carrying a JWT, mirroring the `x-os-auth` header.
40
41
  PARAM_AUTH = "os_auth"
@@ -271,12 +272,29 @@ class Session:
271
272
  """
272
273
  if not isinstance(self._token, SecretKey):
273
274
  raise ValueError("no secret key configured on this session")
274
- return self._token.token_for_scope(
275
- self._usecase.name,
276
- self._scope,
277
- permissions,
278
- expiry_seconds,
275
+
276
+ resolved_expiry = (
277
+ expiry_seconds if expiry_seconds is not None else self._token.expiry_seconds
279
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
280
298
 
281
299
  def _auth_token(self) -> str | None:
282
300
  """Returns a token for internal auth headers."""
@@ -414,9 +432,12 @@ class Session:
414
432
  if key == "":
415
433
  key = None
416
434
 
417
- with measure_storage_operation(
418
- self._metrics_backend, "put", self._usecase.name
419
- ) 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
+ ):
420
441
  retries = None # by default use the pool's value, set by the Client
421
442
  if compress_with != "none":
422
443
  # For on-the-fly compression, don't attempt read retries,
@@ -442,9 +463,21 @@ class Session:
442
463
  # Must do this after streaming `body` as that's what is responsible
443
464
  # for advancing the seek position in both streams
444
465
  if precompressed is None:
445
- metric_emitter.record_uncompressed_size(original_body.tell())
466
+ metrics.record_uncompressed_size(original_body.tell())
446
467
  if encoding != "none":
447
- 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
+ )
448
481
  return res["key"]
449
482
 
450
483
  def get(
@@ -469,38 +502,49 @@ class Session:
469
502
  """
470
503
 
471
504
  headers = self._make_headers()
472
- with measure_storage_operation(
473
- self._metrics_backend, "get", self._usecase.name
474
- ):
475
- response = self._pool.request(
476
- "GET",
477
- self._make_url(key),
478
- preload_content=False,
479
- decode_content=False,
480
- headers=headers,
481
- )
482
- if response.status == 404:
483
- response.read() # drain body so urllib3 returns the connection
484
- return None
485
- raise_for_status(response)
486
- # OR: should I use `response.stream()`?
487
- stream = cast(IO[bytes], response)
488
- metadata = Metadata.from_headers(response.headers)
489
-
490
- encoding_accepted = accept_encoding is not None and (
491
- "*" in accept_encoding or metadata.compression in accept_encoding
492
- )
493
- if metadata.compression and decompress and not encoding_accepted:
494
- if metadata.compression != "zstd":
495
- raise NotImplementedError(
496
- "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,
497
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
+ )
498
529
 
499
- metadata.compression = None
500
- dctx = zstandard.ZstdDecompressor()
501
- stream = dctx.stream_reader(stream, read_across_frames=True)
502
-
503
- 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)
504
548
 
505
549
  def object_url(self, key: str, token_validity: timedelta | None = None) -> str:
506
550
  """
@@ -571,22 +615,32 @@ class Session:
571
615
  )
572
616
  duration_secs = math.ceil(duration.total_seconds())
573
617
 
574
- # `_make_url` already percent-encodes the path identically to the wire.
575
- encoded_path = self._make_url(key)
576
- timestamp = datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
577
- encoded_query = utils.encode_query(
578
- f"{presign.PARAM_KID}={self._token.kid}"
579
- f"&{presign.PARAM_TIMESTAMP}={timestamp}"
580
- f"&{presign.PARAM_DURATION}={duration_secs}"
581
- )
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
+ )
582
634
 
583
- canonical = presign.build_canonical_form(method, encoded_path, encoded_query)
584
- 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)
585
639
 
586
- return (
587
- f"{self._base_url()}"
588
- f"{encoded_path}?{encoded_query}&{presign.PARAM_SIG}={signature}"
589
- )
640
+ return (
641
+ f"{self._base_url()}"
642
+ f"{encoded_path}?{encoded_query}&{presign.PARAM_SIG}={signature}"
643
+ )
590
644
 
591
645
  def head(self, key: str) -> Metadata | None:
592
646
  """
@@ -598,8 +652,11 @@ class Session:
598
652
  and therefore bumps its expiration.
599
653
  """
600
654
  headers = self._make_headers()
601
- with measure_storage_operation(
602
- 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
+ ),
603
660
  ):
604
661
  response = self._pool.request(
605
662
  "HEAD",
@@ -608,8 +665,10 @@ class Session:
608
665
  preload_content=True,
609
666
  )
610
667
  if response.status == 404:
668
+ span.set_attribute("objectstore.found", False)
611
669
  return None
612
670
  raise_for_status(response)
671
+ span.set_attribute("objectstore.found", True)
613
672
  return Metadata.from_headers(response.headers)
614
673
 
615
674
  def delete(self, key: str) -> None:
@@ -618,8 +677,11 @@ class Session:
618
677
  """
619
678
 
620
679
  headers = self._make_headers()
621
- with measure_storage_operation(
622
- 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
+ ),
623
685
  ):
624
686
  response = self._pool.request(
625
687
  "DELETE",
@@ -680,8 +742,13 @@ class Session:
680
742
  if key == "":
681
743
  key = None
682
744
 
683
- with measure_storage_operation(
684
- 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
+ ),
685
752
  ):
686
753
  response = self._pool.request(
687
754
  "POST" if not key else "PUT",
@@ -692,6 +759,9 @@ class Session:
692
759
  )
693
760
  raise_for_status(response)
694
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"])
695
765
  return MultipartUpload(self, res["key"], res["upload_id"])
696
766
 
697
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