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.
- {objectstore_client-0.2.3 → objectstore_client-0.2.5}/PKG-INFO +20 -2
- {objectstore_client-0.2.3 → objectstore_client-0.2.5}/README.md +18 -0
- {objectstore_client-0.2.3 → objectstore_client-0.2.5}/pyproject.toml +2 -2
- {objectstore_client-0.2.3 → objectstore_client-0.2.5}/src/objectstore_client/client.py +156 -63
- {objectstore_client-0.2.3 → objectstore_client-0.2.5}/src/objectstore_client/multipart.py +90 -52
- objectstore_client-0.2.5/src/objectstore_client/tracing.py +56 -0
- {objectstore_client-0.2.3 → objectstore_client-0.2.5}/LICENSE.md +0 -0
- {objectstore_client-0.2.3 → objectstore_client-0.2.5}/src/objectstore_client/__init__.py +0 -0
- {objectstore_client-0.2.3 → objectstore_client-0.2.5}/src/objectstore_client/auth.py +0 -0
- {objectstore_client-0.2.3 → objectstore_client-0.2.5}/src/objectstore_client/errors.py +0 -0
- {objectstore_client-0.2.3 → objectstore_client-0.2.5}/src/objectstore_client/metadata.py +0 -0
- {objectstore_client-0.2.3 → objectstore_client-0.2.5}/src/objectstore_client/metrics.py +0 -0
- {objectstore_client-0.2.3 → objectstore_client-0.2.5}/src/objectstore_client/presign.py +0 -0
- {objectstore_client-0.2.3 → objectstore_client-0.2.5}/src/objectstore_client/py.typed +0 -0
- {objectstore_client-0.2.3 → objectstore_client-0.2.5}/src/objectstore_client/scope.py +0 -0
- {objectstore_client-0.2.3 → objectstore_client-0.2.5}/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.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.
|
|
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.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.
|
|
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:
|
|
104
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
self.
|
|
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
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
466
|
+
metrics.record_uncompressed_size(original_body.tell())
|
|
423
467
|
if encoding != "none":
|
|
424
|
-
|
|
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
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
561
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
|
579
|
-
|
|
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
|
|
599
|
-
|
|
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
|
|
661
|
-
|
|
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
|
|
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
|