projectaria-debug-sharing 0.1.0__py3-none-any.whl
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.
- projectaria_debug_sharing/__init__.py +22 -0
- projectaria_debug_sharing/debug_sharing/__init__.py +15 -0
- projectaria_debug_sharing/debug_sharing/_internal/__init__.py +13 -0
- projectaria_debug_sharing/debug_sharing/_internal/delivery.py +252 -0
- projectaria_debug_sharing/debug_sharing/_internal/file_access.py +130 -0
- projectaria_debug_sharing/debug_sharing/_internal/manifest.py +133 -0
- projectaria_debug_sharing/debug_sharing/_internal/plain_files.py +208 -0
- projectaria_debug_sharing/debug_sharing/_internal/report_command.py +560 -0
- projectaria_debug_sharing/debug_sharing/_internal/spool.py +673 -0
- projectaria_debug_sharing/debug_sharing/cli.py +183 -0
- projectaria_debug_sharing/debug_sharing/collectors.py +472 -0
- projectaria_debug_sharing/debug_sharing/command.py +36 -0
- projectaria_debug_sharing/debug_sharing/facade.py +435 -0
- projectaria_debug_sharing/debug_sharing/flytrap.py +327 -0
- projectaria_debug_sharing/debug_sharing/submitter.py +103 -0
- projectaria_debug_sharing/debug_sharing/types.py +172 -0
- projectaria_debug_sharing-0.1.0.dist-info/METADATA +30 -0
- projectaria_debug_sharing-0.1.0.dist-info/RECORD +22 -0
- projectaria_debug_sharing-0.1.0.dist-info/WHEEL +5 -0
- projectaria_debug_sharing-0.1.0.dist-info/entry_points.txt +2 -0
- projectaria_debug_sharing-0.1.0.dist-info/licenses/LICENSE +201 -0
- projectaria_debug_sharing-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
from collections.abc import Collection
|
|
23
|
+
from typing import Any, Final
|
|
24
|
+
|
|
25
|
+
import aiohttp
|
|
26
|
+
from projectaria_commons.commons.http_helper import HttpHelper
|
|
27
|
+
from projectaria_debug_sharing.debug_sharing.submitter import SubmissionError, Submitter
|
|
28
|
+
from projectaria_debug_sharing.debug_sharing.types import (
|
|
29
|
+
Attachment,
|
|
30
|
+
DebugReport,
|
|
31
|
+
ERROR_CODE_KEY,
|
|
32
|
+
VRS_RECORDING_ID_KEY,
|
|
33
|
+
)
|
|
34
|
+
from projectaria_login import ProjectAriaLogin
|
|
35
|
+
|
|
36
|
+
logger: logging.Logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
REPORT_BUG_URL: Final[str] = "https://graph.oculus.com/report_bug"
|
|
39
|
+
|
|
40
|
+
DEV_SERVER_ENV: Final[str] = "PROJECTARIA_DEV_SERVER"
|
|
41
|
+
"""Names a test server to send reports to instead of production: `NAME` sends to
|
|
42
|
+
`graph.NAME.oculus.com`. Unset for everyone else."""
|
|
43
|
+
|
|
44
|
+
_SANDBOX_NAME: Final[re.Pattern[str]] = re.compile(r"[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*")
|
|
45
|
+
|
|
46
|
+
CATEGORY_ID: Final[str] = "113915505441756"
|
|
47
|
+
"""`PRODUCT_NONE_FBID`: the user had no chance to select a category, no UI being
|
|
48
|
+
shown. The endpoint's only required parameter, so it cannot be omitted, but
|
|
49
|
+
categories are being removed platform-wide and nothing may branch on it."""
|
|
50
|
+
|
|
51
|
+
ENTRY_SOURCE: Final[str] = "projectaria_debug_sharing"
|
|
52
|
+
|
|
53
|
+
CONNECT_TIMEOUT_SEC: Final[float] = 10.0
|
|
54
|
+
|
|
55
|
+
READ_TIMEOUT_SEC: Final[float] = 60.0
|
|
56
|
+
"""How long FlyTrap may stay silent once the whole report is sent. Longer than
|
|
57
|
+
the server's own 30 s limit, so a slow ingest is not mistaken for a dead link."""
|
|
58
|
+
|
|
59
|
+
TOTAL_TIMEOUT_SEC: Final[float] = 1800.0
|
|
60
|
+
"""Cap on one whole submission, so an upload that trickles along without ever
|
|
61
|
+
tripping `READ_TIMEOUT_SEC` still ends. Well under the spool's one-hour claim
|
|
62
|
+
expiry, so a stalled upload cannot outlive its claim and be sent twice."""
|
|
63
|
+
|
|
64
|
+
_PERMANENT_SERVER_ERRORS: Final[frozenset[int]] = frozenset({501, 505})
|
|
65
|
+
"""501 Not Implemented and 505 HTTP Version Not Supported: a retry gets the
|
|
66
|
+
same answer."""
|
|
67
|
+
|
|
68
|
+
_REPORT_FIELDS: Final[frozenset[str]] = frozenset(
|
|
69
|
+
{
|
|
70
|
+
"category_id",
|
|
71
|
+
"description",
|
|
72
|
+
"source_app",
|
|
73
|
+
"entry_source",
|
|
74
|
+
"error_description",
|
|
75
|
+
"session_id",
|
|
76
|
+
"custom_app_metadata",
|
|
77
|
+
"app_id",
|
|
78
|
+
"vrs_recording_id",
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class FlyTrapSubmissionError(SubmissionError):
|
|
84
|
+
"""FlyTrap refused a report, or could not be reached."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class NotLoggedInError(FlyTrapSubmissionError):
|
|
88
|
+
"""Nobody is logged in to Project Aria, so there is no token to send with."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class FlyTrapSubmitter(Submitter):
|
|
92
|
+
"""
|
|
93
|
+
Files a `DebugReport` as a FlyTrap bug through OCGraphReportBugPost.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(self, *, login: ProjectAriaLogin) -> None:
|
|
97
|
+
"""
|
|
98
|
+
Args:
|
|
99
|
+
login: Supplies the token and app ID. Resumed on every submit, so a
|
|
100
|
+
report sent hours later still uses a current token.
|
|
101
|
+
"""
|
|
102
|
+
self._login = login
|
|
103
|
+
|
|
104
|
+
async def submit(self, report: DebugReport) -> str:
|
|
105
|
+
"""
|
|
106
|
+
Args:
|
|
107
|
+
report: The report to file.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The FlyTrap bug ID.
|
|
111
|
+
|
|
112
|
+
Raises:
|
|
113
|
+
FlyTrapSubmissionError: The report was not filed. `retryable` says
|
|
114
|
+
whether sending it again could succeed.
|
|
115
|
+
"""
|
|
116
|
+
token = await self._get_current_token()
|
|
117
|
+
form = self._build_form(report)
|
|
118
|
+
|
|
119
|
+
async with HttpHelper() as http:
|
|
120
|
+
http.set_auth_token(token)
|
|
121
|
+
payload = await self._post(http.session, form)
|
|
122
|
+
|
|
123
|
+
return self._bug_id_from(payload)
|
|
124
|
+
|
|
125
|
+
async def _get_current_token(self) -> str:
|
|
126
|
+
"""
|
|
127
|
+
Raises:
|
|
128
|
+
FlyTrapSubmissionError: Nobody is logged in. Retryable, because
|
|
129
|
+
logging in is what makes the next attempt succeed.
|
|
130
|
+
"""
|
|
131
|
+
await self._login.resume()
|
|
132
|
+
token = self._login.current_token
|
|
133
|
+
if token is None:
|
|
134
|
+
raise NotLoggedInError(
|
|
135
|
+
"No Project Aria login, so the report cannot be sent. "
|
|
136
|
+
"Log in and submit again; the report is kept.",
|
|
137
|
+
retryable=True,
|
|
138
|
+
)
|
|
139
|
+
return token
|
|
140
|
+
|
|
141
|
+
def _build_form(self, report: DebugReport) -> aiohttp.FormData:
|
|
142
|
+
form = aiohttp.FormData()
|
|
143
|
+
for name, value in self._report_fields(report).items():
|
|
144
|
+
form.add_field(name, value)
|
|
145
|
+
for part_name, attachment in _attachments_by_part_name(
|
|
146
|
+
report.attachments
|
|
147
|
+
).items():
|
|
148
|
+
form.add_field(
|
|
149
|
+
part_name,
|
|
150
|
+
attachment.data,
|
|
151
|
+
filename=attachment.filename or attachment.name,
|
|
152
|
+
content_type=attachment.content_type,
|
|
153
|
+
)
|
|
154
|
+
return form
|
|
155
|
+
|
|
156
|
+
def _report_fields(self, report: DebugReport) -> dict[str, str]:
|
|
157
|
+
"""
|
|
158
|
+
`app_id` is the login's, so it names the app that actually filed the
|
|
159
|
+
report. `vrs_recording_id` is sent only when the report names one.
|
|
160
|
+
"""
|
|
161
|
+
fields = {
|
|
162
|
+
"category_id": CATEGORY_ID,
|
|
163
|
+
"description": report.description,
|
|
164
|
+
"source_app": report.source_app,
|
|
165
|
+
"entry_source": ENTRY_SOURCE,
|
|
166
|
+
"error_description": report.metadata.get(ERROR_CODE_KEY, ""),
|
|
167
|
+
"session_id": report.report_id,
|
|
168
|
+
"custom_app_metadata": json.dumps(dict(report.metadata)),
|
|
169
|
+
}
|
|
170
|
+
app_id = self._login.current_app_id
|
|
171
|
+
if app_id is not None:
|
|
172
|
+
fields["app_id"] = app_id
|
|
173
|
+
recording_id = report.metadata.get(VRS_RECORDING_ID_KEY)
|
|
174
|
+
if recording_id:
|
|
175
|
+
fields["vrs_recording_id"] = recording_id
|
|
176
|
+
return fields
|
|
177
|
+
|
|
178
|
+
async def _post(
|
|
179
|
+
self, session: aiohttp.ClientSession, form: aiohttp.FormData
|
|
180
|
+
) -> dict[str, Any]:
|
|
181
|
+
"""
|
|
182
|
+
Sends the form and returns FlyTrap's JSON response. Status is checked
|
|
183
|
+
here rather than by HttpHelper's session, which would discard the body
|
|
184
|
+
that says why FlyTrap refused.
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
FlyTrapSubmissionError: On an HTTP error, a malformed body or a
|
|
188
|
+
transport failure. A read timeout is not retryable: the whole
|
|
189
|
+
report had been sent, so FlyTrap may have filed it.
|
|
190
|
+
"""
|
|
191
|
+
url = report_bug_url()
|
|
192
|
+
timeout = aiohttp.ClientTimeout(
|
|
193
|
+
total=TOTAL_TIMEOUT_SEC,
|
|
194
|
+
sock_connect=CONNECT_TIMEOUT_SEC,
|
|
195
|
+
sock_read=READ_TIMEOUT_SEC,
|
|
196
|
+
)
|
|
197
|
+
try:
|
|
198
|
+
async with session.post(
|
|
199
|
+
url, data=form, timeout=timeout, raise_for_status=False
|
|
200
|
+
) as response:
|
|
201
|
+
body = await response.text()
|
|
202
|
+
if response.status >= 400:
|
|
203
|
+
raise FlyTrapSubmissionError(
|
|
204
|
+
f"FlyTrap returned HTTP {response.status}: {body[:500]}",
|
|
205
|
+
retryable=_is_retryable_status(response.status),
|
|
206
|
+
)
|
|
207
|
+
return self._parse(body)
|
|
208
|
+
except aiohttp.SocketTimeoutError as e:
|
|
209
|
+
raise FlyTrapSubmissionError(
|
|
210
|
+
f"FlyTrap did not answer within {READ_TIMEOUT_SEC:g}s. The report "
|
|
211
|
+
"may have been filed, so it is not sent again.",
|
|
212
|
+
retryable=False,
|
|
213
|
+
) from e
|
|
214
|
+
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
|
|
215
|
+
raise FlyTrapSubmissionError(
|
|
216
|
+
f"Could not reach FlyTrap: {_describe(e)}", retryable=True
|
|
217
|
+
) from e
|
|
218
|
+
|
|
219
|
+
def _parse(self, body: str) -> dict[str, Any]:
|
|
220
|
+
"""
|
|
221
|
+
Raises:
|
|
222
|
+
FlyTrapSubmissionError: The body is not a JSON object. Not retryable.
|
|
223
|
+
"""
|
|
224
|
+
try:
|
|
225
|
+
payload = json.loads(body)
|
|
226
|
+
except json.JSONDecodeError as e:
|
|
227
|
+
raise FlyTrapSubmissionError(
|
|
228
|
+
f"FlyTrap returned a non-JSON body: {body[:500]}", retryable=False
|
|
229
|
+
) from e
|
|
230
|
+
if not isinstance(payload, dict):
|
|
231
|
+
raise FlyTrapSubmissionError(
|
|
232
|
+
f"FlyTrap returned {type(payload).__name__}, expected an object",
|
|
233
|
+
retryable=False,
|
|
234
|
+
)
|
|
235
|
+
return payload
|
|
236
|
+
|
|
237
|
+
def _bug_id_from(self, payload: dict[str, Any]) -> str:
|
|
238
|
+
"""
|
|
239
|
+
Raises:
|
|
240
|
+
FlyTrapSubmissionError: FlyTrap refused the report, or reported
|
|
241
|
+
success without a `bug_id`. A refusal is retryable only when
|
|
242
|
+
FlyTrap says so and did not prohibit the report; its message
|
|
243
|
+
carries the whole response, since that is where the trace ID
|
|
244
|
+
is. A success without a `bug_id` is not retried: the report
|
|
245
|
+
probably landed, and resending would file a duplicate.
|
|
246
|
+
"""
|
|
247
|
+
if not payload.get("success"):
|
|
248
|
+
prohibited = bool(payload.get("is_prohibited"))
|
|
249
|
+
raise FlyTrapSubmissionError(
|
|
250
|
+
f"{payload.get('error_message') or 'FlyTrap rejected the report'} "
|
|
251
|
+
f"-- full response: {json.dumps(payload, sort_keys=True)[:1000]}",
|
|
252
|
+
retryable=bool(payload.get("is_retryable")) and not prohibited,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
bug_id = payload.get("bug_id")
|
|
256
|
+
if not bug_id:
|
|
257
|
+
raise FlyTrapSubmissionError(
|
|
258
|
+
"FlyTrap reported success but returned no bug_id", retryable=False
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
logger.debug("Submitted debug report as FlyTrap bug %s", bug_id)
|
|
262
|
+
return str(bug_id)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def report_bug_url() -> str:
|
|
266
|
+
"""
|
|
267
|
+
Production, or the test server `PROJECTARIA_DEV_SERVER` names. Only a name made
|
|
268
|
+
of host labels is accepted, so the report and its token can only go to an
|
|
269
|
+
oculus.com host, over https.
|
|
270
|
+
|
|
271
|
+
Raises:
|
|
272
|
+
FlyTrapSubmissionError: `PROJECTARIA_DEV_SERVER` is not a server name.
|
|
273
|
+
Not retryable: a kept report does not record where it was meant to
|
|
274
|
+
go, so a later drain without the variable would send it to
|
|
275
|
+
production.
|
|
276
|
+
"""
|
|
277
|
+
sandbox = os.environ.get(DEV_SERVER_ENV)
|
|
278
|
+
if not sandbox:
|
|
279
|
+
return REPORT_BUG_URL
|
|
280
|
+
if not _SANDBOX_NAME.fullmatch(sandbox):
|
|
281
|
+
raise FlyTrapSubmissionError(
|
|
282
|
+
f"{DEV_SERVER_ENV}={sandbox!r} is not a host name",
|
|
283
|
+
retryable=False,
|
|
284
|
+
)
|
|
285
|
+
url = f"https://graph.{sandbox}.oculus.com/report_bug"
|
|
286
|
+
logger.debug("Sending to %s, not production: %s is set", url, DEV_SERVER_ENV)
|
|
287
|
+
return url
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _attachments_by_part_name(
|
|
291
|
+
attachments: Collection[Attachment],
|
|
292
|
+
) -> dict[str, Attachment]:
|
|
293
|
+
"""
|
|
294
|
+
Gives every attachment a multipart field name of its own, so the server
|
|
295
|
+
keeps all of them.
|
|
296
|
+
"""
|
|
297
|
+
parts: dict[str, Attachment] = {}
|
|
298
|
+
for attachment in attachments:
|
|
299
|
+
parts[_unique_part_name(attachment.name, taken=parts.keys())] = attachment
|
|
300
|
+
return parts
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _unique_part_name(name: str, *, taken: Collection[str]) -> str:
|
|
304
|
+
"""
|
|
305
|
+
`name`, prefixed with `attachment_` if it is a report field's, and given a
|
|
306
|
+
numeric suffix if an earlier attachment already uses it.
|
|
307
|
+
"""
|
|
308
|
+
base = f"attachment_{name}" if name in _REPORT_FIELDS else name
|
|
309
|
+
candidate = base
|
|
310
|
+
suffix = 2
|
|
311
|
+
while candidate in taken or candidate in _REPORT_FIELDS:
|
|
312
|
+
candidate = f"{base}_{suffix}"
|
|
313
|
+
suffix += 1
|
|
314
|
+
return candidate
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _is_retryable_status(status: int) -> bool:
|
|
318
|
+
"""Whether an HTTP error status is transient: 429, or any 5xx except
|
|
319
|
+
501 and 505, which reject the request itself."""
|
|
320
|
+
return status == 429 or (
|
|
321
|
+
500 <= status < 600 and status not in _PERMANENT_SERVER_ERRORS
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _describe(error: BaseException) -> str:
|
|
326
|
+
"""The error's message, or its type for one with none (a bare `TimeoutError`)."""
|
|
327
|
+
return str(error) or type(error).__name__
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""The boundary between assembling a report and delivering it."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Protocol, runtime_checkable
|
|
22
|
+
|
|
23
|
+
from projectaria_debug_sharing.debug_sharing.types import DebugReport
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SubmissionError(Exception):
|
|
27
|
+
"""A report was not delivered."""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
message: str,
|
|
32
|
+
*,
|
|
33
|
+
report: DebugReport | None = None,
|
|
34
|
+
retryable: bool = True,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Args:
|
|
38
|
+
message: Why, in words the user can act on.
|
|
39
|
+
report: Set by whoever assembled the report, so a caller can spool it.
|
|
40
|
+
retryable: Whether sending the same report again could succeed.
|
|
41
|
+
Defaults to True: a wrong True costs one more attempt, a wrong
|
|
42
|
+
False drops a report for good.
|
|
43
|
+
"""
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.report = report
|
|
46
|
+
self.retryable = retryable
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@runtime_checkable
|
|
50
|
+
class Submitter(Protocol):
|
|
51
|
+
"""Delivers a report, returns the bug ID. Raises SubmissionError."""
|
|
52
|
+
|
|
53
|
+
async def submit(self, report: DebugReport) -> str: ...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Delivery:
|
|
58
|
+
"""What happened to one report sent now."""
|
|
59
|
+
|
|
60
|
+
bug_id: str | None = None
|
|
61
|
+
"""The filed bug's id; None when the report was not filed."""
|
|
62
|
+
|
|
63
|
+
error: SubmissionError | None = None
|
|
64
|
+
"""Why the report was not filed."""
|
|
65
|
+
|
|
66
|
+
spooled: Path | None = None
|
|
67
|
+
"""Where the report was kept for a later drain; None when it was filed, was
|
|
68
|
+
refused, or could not be kept."""
|
|
69
|
+
|
|
70
|
+
def __post_init__(self) -> None:
|
|
71
|
+
"""
|
|
72
|
+
Raises:
|
|
73
|
+
ValueError: Not exactly one of `bug_id` and `error` is set, or a
|
|
74
|
+
filed report claims to be spooled.
|
|
75
|
+
"""
|
|
76
|
+
if (self.bug_id is None) == (self.error is None):
|
|
77
|
+
raise ValueError("a Delivery has exactly one of bug_id and error")
|
|
78
|
+
if self.bug_id is not None and self.spooled is not None:
|
|
79
|
+
raise ValueError("a filed report is not spooled")
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def will_retry(self) -> bool:
|
|
83
|
+
"""Whether a later drain sends the report: kept, and the failure retryable."""
|
|
84
|
+
return (
|
|
85
|
+
self.spooled is not None and self.error is not None and self.error.retryable
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class DrainResult:
|
|
91
|
+
"""What a drain of the kept reports did."""
|
|
92
|
+
|
|
93
|
+
sent: tuple[str, ...] = ()
|
|
94
|
+
"""The bug ids of the reports filed."""
|
|
95
|
+
|
|
96
|
+
waiting: int = 0
|
|
97
|
+
"""How many reports this drain tried that failed and wait for the next one.
|
|
98
|
+
Reports it skipped, or that another drain was sending, are not counted."""
|
|
99
|
+
|
|
100
|
+
rejected: int = 0
|
|
101
|
+
"""How many reports will never be sent: refused by the destination or
|
|
102
|
+
locally, or unreadable on disk. They are kept marked, or deleted if they
|
|
103
|
+
cannot be."""
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import uuid
|
|
18
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from types import MappingProxyType
|
|
22
|
+
from typing import Final
|
|
23
|
+
|
|
24
|
+
VRS_RECORDING_ID_KEY: Final[str] = "vrs_recording_id"
|
|
25
|
+
"""`DebugReport.metadata` key naming the VRS recording the report is about. One
|
|
26
|
+
recording only."""
|
|
27
|
+
|
|
28
|
+
ERROR_CODE_KEY: Final[str] = "error_code"
|
|
29
|
+
"""`DebugReport.metadata` key for the error code that prompted the report."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CollectionScope(Enum):
|
|
33
|
+
"""
|
|
34
|
+
A category of data a collector gathers, and the unit consent is given in.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
LOGS = "logs"
|
|
38
|
+
ENVIRONMENT = "environment"
|
|
39
|
+
DIAGNOSTICS = "diagnostics"
|
|
40
|
+
RECORDING = "recording"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class Attachment:
|
|
45
|
+
"""One file we upload."""
|
|
46
|
+
|
|
47
|
+
name: str
|
|
48
|
+
"""Short label, sent as the form field name."""
|
|
49
|
+
|
|
50
|
+
data: bytes
|
|
51
|
+
|
|
52
|
+
content_type: str = "application/octet-stream"
|
|
53
|
+
|
|
54
|
+
filename: str | None = None
|
|
55
|
+
"""Filename sent with the part. When None, callers fall back to `name`."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True, kw_only=True)
|
|
59
|
+
class DebugReport:
|
|
60
|
+
"""
|
|
61
|
+
Everything we send for one bug.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
description: str
|
|
65
|
+
source_app: str
|
|
66
|
+
attachments: tuple[Attachment, ...] = ()
|
|
67
|
+
|
|
68
|
+
metadata: Mapping[str, str] = field(default_factory=lambda: MappingProxyType({}))
|
|
69
|
+
"""Read-only copy: `frozen=True` alone would still let a caller mutate their
|
|
70
|
+
own dict, or the dict behind their `MappingProxyType`, after the consent
|
|
71
|
+
prompt described it. Values become strings, and `None` values are dropped
|
|
72
|
+
rather than sent as `"None"`."""
|
|
73
|
+
|
|
74
|
+
report_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
75
|
+
"""Correlates the PC and device halves. Sent as `session_id`, which the
|
|
76
|
+
endpoint copies into `client_server_join_key`."""
|
|
77
|
+
|
|
78
|
+
def __post_init__(self) -> None:
|
|
79
|
+
object.__setattr__(
|
|
80
|
+
self,
|
|
81
|
+
"metadata",
|
|
82
|
+
MappingProxyType(
|
|
83
|
+
{str(k): str(v) for k, v in self.metadata.items() if v is not None}
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class SpooledReportError(Exception):
|
|
89
|
+
"""
|
|
90
|
+
A spooled report could not be read. A drain marks it refused, so it is
|
|
91
|
+
never sent, and goes on to the next one.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ConsentSource(Enum):
|
|
96
|
+
"""Where a report's consent came from, recorded on the report."""
|
|
97
|
+
|
|
98
|
+
PROMPTED = "prompted"
|
|
99
|
+
"""The user answered the tool's consent prompt."""
|
|
100
|
+
|
|
101
|
+
CALLER = "caller"
|
|
102
|
+
"""The calling tool obtained consent itself, for example its own question."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class OfferedCollector:
|
|
107
|
+
"""One line of the consent prompt, and the collector it speaks for."""
|
|
108
|
+
|
|
109
|
+
name: str
|
|
110
|
+
scope: CollectionScope
|
|
111
|
+
disclosure: str
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class ConsentRequest:
|
|
116
|
+
"""What a front end needs to render an accurate consent prompt."""
|
|
117
|
+
|
|
118
|
+
source_app: str
|
|
119
|
+
|
|
120
|
+
offered: tuple[OfferedCollector, ...]
|
|
121
|
+
"""Every collector that would run, with the sentence to show for it. Taken
|
|
122
|
+
from the list that does the collecting, so the prompt cannot describe
|
|
123
|
+
something that will not run, or omit something that will."""
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def scopes(self) -> frozenset[CollectionScope]:
|
|
127
|
+
"""The categories on offer, which is what the user chooses between."""
|
|
128
|
+
return frozenset(o.scope for o in self.offered)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
RequestConsent = Callable[
|
|
132
|
+
[ConsentRequest], Awaitable["frozenset[CollectionScope] | None"]
|
|
133
|
+
]
|
|
134
|
+
"""Returns the granted scopes. None means the user declined outright; an empty
|
|
135
|
+
set still sends the description and caller metadata, with no attachments."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class CollectorFailure:
|
|
140
|
+
"""A collector that raised. Collection is best effort, so these are reported."""
|
|
141
|
+
|
|
142
|
+
collector_name: str
|
|
143
|
+
|
|
144
|
+
error: str
|
|
145
|
+
"""The exception's message, or its class name when the message is empty."""
|
|
146
|
+
|
|
147
|
+
error_type: str = ""
|
|
148
|
+
"""The exception's class name. Empty when the failure was built without one."""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class ShareOutcome:
|
|
153
|
+
"""Everything the caller needs to tell the user what happened."""
|
|
154
|
+
|
|
155
|
+
bug_id: str
|
|
156
|
+
report_id: str
|
|
157
|
+
|
|
158
|
+
failures: tuple[CollectorFailure, ...] = field(default_factory=tuple)
|
|
159
|
+
"""Collectors that failed. The report was still submitted; the caller should
|
|
160
|
+
say so, so the user knows the bundle is incomplete."""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class ConsentDeclinedError(Exception):
|
|
164
|
+
"""The user declined to share. Nothing was collected or sent."""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class NoConsentBasisError(Exception):
|
|
168
|
+
"""
|
|
169
|
+
Nothing says the user agreed to this. Raised rather than assuming a grant:
|
|
170
|
+
a missing prompt is far more likely a caller that forgot one than a caller
|
|
171
|
+
that meant to collect everything.
|
|
172
|
+
"""
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: projectaria-debug-sharing
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Project Aria Debug Sharing Library
|
|
5
|
+
Home-page: https://www.projectaria.com
|
|
6
|
+
Author: Meta Reality Labs Research
|
|
7
|
+
License: Apache-2.0
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: aiohttp>=3.10
|
|
12
|
+
Requires-Dist: projectaria-commons~=0.1.0
|
|
13
|
+
Requires-Dist: projectaria-login~=0.2.0
|
|
14
|
+
Dynamic: author
|
|
15
|
+
Dynamic: description
|
|
16
|
+
Dynamic: description-content-type
|
|
17
|
+
Dynamic: home-page
|
|
18
|
+
Dynamic: license
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
Dynamic: requires-dist
|
|
21
|
+
Dynamic: requires-python
|
|
22
|
+
Dynamic: summary
|
|
23
|
+
|
|
24
|
+
Shared debug-sharing library for Project Aria tools.
|
|
25
|
+
|
|
26
|
+
Collects diagnostic logs and environment details, captures the user's consent,
|
|
27
|
+
and submits a correlated bug report, so MPS CLI, Client SDK and Aria Studio can
|
|
28
|
+
offer one consistent reporting experience.
|
|
29
|
+
|
|
30
|
+
For more information, visit [Project Aria](https://www.projectaria.com).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
projectaria_debug_sharing/__init__.py,sha256=zsf453g7MRWide6tT4tf_CzY-Wr6i-pOiS4C6CZaRb0,749
|
|
2
|
+
projectaria_debug_sharing/debug_sharing/__init__.py,sha256=6xZPTzPtzy-lDojjgRGorJj9GQd2pzDdlt3gzc8rgAU,614
|
|
3
|
+
projectaria_debug_sharing/debug_sharing/cli.py,sha256=8dMxP-NqlfS3IaD3C6aIp8gO8lQuKSa77ElusGBA5Bw,6107
|
|
4
|
+
projectaria_debug_sharing/debug_sharing/collectors.py,sha256=bGtxY0BVuUUDdJXPTXcUrPnwTzg_p-L64Ug3AfD0Vjg,16547
|
|
5
|
+
projectaria_debug_sharing/debug_sharing/command.py,sha256=ObUYs5be8AXKTy9JaB4LNjwmSSe7Ahtvk-4SKg0fQd4,1070
|
|
6
|
+
projectaria_debug_sharing/debug_sharing/facade.py,sha256=kvxa0DUTVX9xE_HdWL5wacVgHlKrpO9-KwFit6Mhn3I,16775
|
|
7
|
+
projectaria_debug_sharing/debug_sharing/flytrap.py,sha256=yjYMp9_T0IZOHKBcviAvA5rpylKtuMYhwxc3dg-EPK8,12081
|
|
8
|
+
projectaria_debug_sharing/debug_sharing/submitter.py,sha256=Yks6KZt5uTLaPWJ9aFHU1wojFJEy4mzdolXDlXg16rQ,3453
|
|
9
|
+
projectaria_debug_sharing/debug_sharing/types.py,sha256=j5LnuyoSPbw3d3VkwEuEzUqBafLbKPVrnaqwiFCt2iA,5269
|
|
10
|
+
projectaria_debug_sharing/debug_sharing/_internal/__init__.py,sha256=ATD5Za7LwP-bGqN8PQsTyTzFrl7ER2QgIs_8NoIhZiU,599
|
|
11
|
+
projectaria_debug_sharing/debug_sharing/_internal/delivery.py,sha256=cJu28RWV9PW3yLocgnfoWDhRVbTQw20N5IyMWrCsclc,9731
|
|
12
|
+
projectaria_debug_sharing/debug_sharing/_internal/file_access.py,sha256=UwrhBTm3DnTtPmytCFGgbIkUYhNIl858PF4l25ysq5Y,4090
|
|
13
|
+
projectaria_debug_sharing/debug_sharing/_internal/manifest.py,sha256=pi91XZmjQEecmUrB5UhSQUsQS-46hOQVd_isCZaWoZg,4836
|
|
14
|
+
projectaria_debug_sharing/debug_sharing/_internal/plain_files.py,sha256=FOnRuOyxQheUE0pf5Q0rgF4x0WMRn2_w7QTCy8fnjxc,6761
|
|
15
|
+
projectaria_debug_sharing/debug_sharing/_internal/report_command.py,sha256=pPkhZD7DLgJfbbXDik3l-PjLoV7P4zKxealdHSHV7tE,21287
|
|
16
|
+
projectaria_debug_sharing/debug_sharing/_internal/spool.py,sha256=Te3lOGj0ltbXvujA4j5gSGJ6cpfdk41YhlaN0OZaenw,25615
|
|
17
|
+
projectaria_debug_sharing-0.1.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
18
|
+
projectaria_debug_sharing-0.1.0.dist-info/METADATA,sha256=ZnkwrCQaxNJehUnRIB-BzkhDeEeku44LeMPWmUZErkM,929
|
|
19
|
+
projectaria_debug_sharing-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
20
|
+
projectaria_debug_sharing-0.1.0.dist-info/entry_points.txt,sha256=6RgKvfI0LpOPLv42y9CnY4lCEa--GqA_i7NEzPxdrVo,87
|
|
21
|
+
projectaria_debug_sharing-0.1.0.dist-info/top_level.txt,sha256=SsQLlgUmT4TeA3KS5pq2MuLtJO6yMW-HKlIWC6cxd8c,26
|
|
22
|
+
projectaria_debug_sharing-0.1.0.dist-info/RECORD,,
|