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.
- {objectstore_client-0.2.4 → objectstore_client-0.2.6}/PKG-INFO +20 -2
- {objectstore_client-0.2.4 → objectstore_client-0.2.6}/README.md +18 -0
- {objectstore_client-0.2.4 → objectstore_client-0.2.6}/pyproject.toml +2 -2
- {objectstore_client-0.2.4 → objectstore_client-0.2.6}/src/objectstore_client/client.py +130 -60
- {objectstore_client-0.2.4 → objectstore_client-0.2.6}/src/objectstore_client/multipart.py +90 -52
- objectstore_client-0.2.6/src/objectstore_client/tracing.py +56 -0
- {objectstore_client-0.2.4 → objectstore_client-0.2.6}/LICENSE.md +0 -0
- {objectstore_client-0.2.4 → objectstore_client-0.2.6}/src/objectstore_client/__init__.py +0 -0
- {objectstore_client-0.2.4 → objectstore_client-0.2.6}/src/objectstore_client/auth.py +0 -0
- {objectstore_client-0.2.4 → objectstore_client-0.2.6}/src/objectstore_client/errors.py +0 -0
- {objectstore_client-0.2.4 → objectstore_client-0.2.6}/src/objectstore_client/metadata.py +0 -0
- {objectstore_client-0.2.4 → objectstore_client-0.2.6}/src/objectstore_client/metrics.py +0 -0
- {objectstore_client-0.2.4 → objectstore_client-0.2.6}/src/objectstore_client/presign.py +0 -0
- {objectstore_client-0.2.4 → objectstore_client-0.2.6}/src/objectstore_client/py.typed +0 -0
- {objectstore_client-0.2.4 → objectstore_client-0.2.6}/src/objectstore_client/scope.py +0 -0
- {objectstore_client-0.2.4 → objectstore_client-0.2.6}/src/objectstore_client/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: objectstore-client
|
|
3
|
-
Version: 0.2.
|
|
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.
|
|
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
|
+
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.
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
self.
|
|
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
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
466
|
+
metrics.record_uncompressed_size(original_body.tell())
|
|
446
467
|
if encoding != "none":
|
|
447
|
-
|
|
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
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
584
|
-
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
|
602
|
-
|
|
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
|
|
622
|
-
|
|
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
|
|
684
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
"
|
|
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
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|