diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 695ebe63..780dd7dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,8 @@ permissions: jobs: check: - runs-on: ubuntu-latest + # Pinned: setup-python has no 3.14.4 build for ubuntu-26.04 (ubuntu-latest); unpin once .python-version is bumped + runs-on: ubuntu-24.04 steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -45,7 +46,8 @@ jobs: run: make check-deps test: - runs-on: ubuntu-latest + # Pinned: setup-python has no 3.14.4 build for ubuntu-26.04 (ubuntu-latest); unpin once .python-version is bumped + runs-on: ubuntu-24.04 steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -162,7 +164,8 @@ jobs: run: docker compose -f docker-compose.e2e.yml down -v build: - runs-on: ubuntu-latest + # Pinned: setup-python has no 3.14.4 build for ubuntu-26.04 (ubuntu-latest); unpin once .python-version is bumped + runs-on: ubuntu-24.04 needs: [check, test, e2e] steps: diff --git a/pyproject.toml b/pyproject.toml index 356813b5..48b89675 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ "rich>=14.1.0", "sentry-arroyo>=2.47.0", "sentry-options==1.2.10", - "sentry-sdk>=2.36.0", + "sentry-sdk>=2.70.0", "sortedcontainers>=2.4.0", "taskbroker-client>=0.20.26,<1", "typing-extensions>=4.15.0", diff --git a/src/launchpad/artifact_processor.py b/src/launchpad/artifact_processor.py index 95b28e1d..d7aa3cbd 100644 --- a/src/launchpad/artifact_processor.py +++ b/src/launchpad/artifact_processor.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import Any, Iterator, cast -import sentry_sdk +import sentry_sdk.traces from objectstore_client import ( Client as ObjectstoreClient, @@ -138,11 +138,21 @@ def process_message( with contextlib.ExitStack() as stack: stack.enter_context(request_context(artifact_id=artifact_id)) processing_start = time.monotonic() - scope = stack.enter_context(sentry_sdk.new_scope()) - scope.set_tag("launchpad.project_id", project_id) - scope.set_tag("launchpad.organization_id", organization_id) - scope.set_tag("launchpad.preprod_artifact_id", artifact_id) - stack.enter_context(scope.start_transaction(op="subprocess", name="launchpad.process_message")) + scope = stack.enter_context(sentry_sdk.isolation_scope()) + sentry_sdk.traces.new_trace() + artifact_attributes = { + "launchpad.project_id": project_id, + "launchpad.organization_id": organization_id, + "launchpad.preprod_artifact_id": artifact_id, + } + # Tags apply to errors, attributes apply to spans and logs + scope.set_tags(artifact_attributes) + scope.set_attributes(artifact_attributes) + stack.enter_context( + sentry_sdk.traces.start_span( + name="launchpad.process_message", attributes={"sentry.op": "subprocess"}, parent_span=None + ) + ) statsd.increment("artifact.processing.started") logger.info(f"Processing artifact {artifact_id} (project: {project_id}, org: {organization_id})") try: diff --git a/src/launchpad/artifacts/android/aab.py b/src/launchpad/artifacts/android/aab.py index ec59c918..54c8d6dc 100644 --- a/src/launchpad/artifacts/android/aab.py +++ b/src/launchpad/artifacts/android/aab.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Callable -import sentry_sdk +import sentry_sdk.traces from launchpad.parsers.android.dex.dex_mapping import DexMapping from launchpad.parsers.android.icon.proto_xml_drawable_parser import ( @@ -41,7 +41,7 @@ def __init__(self, path: Path, cleanup: None | Callable[[], None] = None) -> Non self._dex_mapping: DexMapping | None = None self._universal_apk: APK | None = None - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_manifest(self) -> AndroidManifest: if self._manifest is not None: return self._manifest @@ -61,7 +61,7 @@ def get_manifest(self) -> AndroidManifest: self._manifest = ProtoXmlUtils.proto_xml_to_android_manifest(manifest_buffer, proto_res_tables) return self._manifest - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_resource_tables(self) -> list[ProtobufResourceTable]: # type: ignore[override] if self._resource_table is not None: return [self._resource_table] @@ -79,7 +79,7 @@ def get_resource_tables(self) -> list[ProtobufResourceTable]: # type: ignore[ov self._resource_table = ProtobufResourceTable(arsc_buffer) return [self._resource_table] - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_primary_apks(self, device_spec: DeviceSpec = DeviceSpec()) -> list[APK]: if self._primary_apks is not None: return self._primary_apks @@ -87,17 +87,19 @@ def get_primary_apks(self, device_spec: DeviceSpec = DeviceSpec()) -> list[APK]: apks_dir = create_temp_directory("apks-") try: bundletool = Bundletool() - with sentry_sdk.start_span(op="aab", description="aab.build_primary_apks"): + with sentry_sdk.traces.start_span(name="aab.build_primary_apks", attributes={"sentry.op": "aab"}): bundletool.build_apks(bundle_path=self.path, output_dir=apks_dir, device_spec=device_spec) apks = [] for apk_path in apks_dir.glob("*.apk"): tmp_dir = Path(tempfile.mkdtemp()) new_apk_path = tmp_dir / apk_path.name - with sentry_sdk.start_span(op="file.copy", description="aab.copy_primary_apk") as span: - span.set_data("apk.size", apk_path.stat().st_size) + with sentry_sdk.traces.start_span( + name="aab.copy_primary_apk", attributes={"sentry.op": "file.copy"} + ) as span: + span.set_attribute("apk.size", apk_path.stat().st_size) shutil.copyfile(apk_path, new_apk_path) - with sentry_sdk.start_span(op="aab", description="aab.initialize_primary_apk"): + with sentry_sdk.traces.start_span(name="aab.initialize_primary_apk", attributes={"sentry.op": "aab"}): apks.append( APK( new_apk_path, @@ -109,10 +111,12 @@ def get_primary_apks(self, device_spec: DeviceSpec = DeviceSpec()) -> list[APK]: self._primary_apks = apks return apks finally: - with sentry_sdk.start_span(op="file.cleanup", description="aab.cleanup_primary_apks"): + with sentry_sdk.traces.start_span( + name="aab.cleanup_primary_apks", attributes={"sentry.op": "file.cleanup"} + ): cleanup_directory(apks_dir) - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_universal_apk(self, apk_dir: Path, device_spec: DeviceSpec = DeviceSpec()) -> APK: if self._universal_apk is not None: return self._universal_apk @@ -152,7 +156,7 @@ def get_dex_mapping(self) -> DexMapping | None: self._dex_mapping = DexMapping(dex_mapping_buffer) return self._dex_mapping - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_app_icon(self) -> bytes | None: manifest = self.get_manifest() if manifest.application is None: diff --git a/src/launchpad/artifacts/android/apk.py b/src/launchpad/artifacts/android/apk.py index 4deecefe..a348859e 100644 --- a/src/launchpad/artifacts/android/apk.py +++ b/src/launchpad/artifacts/android/apk.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import IO, Callable, Iterator -import sentry_sdk +import sentry_sdk.traces from launchpad.parsers.android.dex.dex_mapping import DexMapping from launchpad.parsers.android.icon.binary_xml_drawable_parser import ( @@ -40,7 +40,7 @@ def __init__( super().__init__(path, cleanup=cleanup) self._dex_mapping = dex_mapping self._zip_provider = ZipProvider(path) - with sentry_sdk.start_span(op="file.extract", description="apk.extract"): + with sentry_sdk.traces.start_span(name="apk.extract", attributes={"sentry.op": "file.extract"}): self._extract_dir = self._zip_provider.extract_to_temp_directory() self._manifest: AndroidManifest | None = None self._resource_table: BinaryResourceTable | None = None @@ -54,7 +54,7 @@ def raw_file(self) -> Iterator[IO[bytes]]: finally: f.close() - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_manifest(self) -> AndroidManifest: if self._manifest is not None: return self._manifest @@ -74,7 +74,7 @@ def get_manifest(self) -> AndroidManifest: self._manifest = AxmlUtils.binary_xml_to_android_manifest(manifest_buffer, binary_res_tables) return self._manifest - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_resource_tables(self) -> list[BinaryResourceTable]: # type: ignore[override] if self._resource_table is not None: return [self._resource_table] @@ -94,7 +94,7 @@ def get_resource_tables(self) -> list[BinaryResourceTable]: # type: ignore[over self._resource_table = BinaryResourceTable(arsc_buffer) return [self._resource_table] - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_class_definitions(self) -> list[ClassDefinition]: if self._class_definitions is not None: return self._class_definitions @@ -122,25 +122,23 @@ def get_class_definitions(self) -> list[ClassDefinition]: logger.error(f"Failed to read DEX file {dex_file}: {e}") continue - if (span := sentry_sdk.get_current_span()) is not None: - span.set_data("dex_stats.file_count", dex_stats.file_count) - span.set_data("dex_stats.total_bytes", dex_stats.total_bytes) - span.set_data("dex_stats.read_duration_ms", dex_stats.read_duration_ms) - span.set_data("dex_stats.parser.duration_ms", dex_stats.parser_duration_ms) - span.set_data("dex_stats.parser.method_duration_ms", dex_stats.parser_method_duration_ms) - span.set_data("dex_stats.parser.field_duration_ms", dex_stats.parser_field_duration_ms) - span.set_data("dex_stats.parser.class_count", dex_stats.parser_class_count) - span.set_data("dex_stats.parser.method_count", dex_stats.parser_method_count) - span.set_data("dex_stats.parser.field_count", dex_stats.parser_field_count) - span.set_data( - "dex_stats.parser.method_annotation_scan_steps", - dex_stats.parser_method_annotation_scan_steps, + if (span := sentry_sdk.traces.get_current_span()) is not None: + span.set_attributes( + { + "dex_stats.file_count": dex_stats.file_count, + "dex_stats.total_bytes": dex_stats.total_bytes, + "dex_stats.read_duration_ms": dex_stats.read_duration_ms, + "dex_stats.parser.duration_ms": dex_stats.parser_duration_ms, + "dex_stats.parser.method_duration_ms": dex_stats.parser_method_duration_ms, + "dex_stats.parser.field_duration_ms": dex_stats.parser_field_duration_ms, + "dex_stats.parser.class_count": dex_stats.parser_class_count, + "dex_stats.parser.method_count": dex_stats.parser_method_count, + "dex_stats.parser.field_count": dex_stats.parser_field_count, + "dex_stats.parser.method_annotation_scan_steps": dex_stats.parser_method_annotation_scan_steps, + "dex_stats.parser.field_annotation_scan_steps": dex_stats.parser_field_annotation_scan_steps, + "dex_stats.parser.mapping_present": dex_stats.parser_mapping_present, + } ) - span.set_data( - "dex_stats.parser.field_annotation_scan_steps", - dex_stats.parser_field_annotation_scan_steps, - ) - span.set_data("dex_stats.parser.mapping_present", dex_stats.parser_mapping_present) return self._class_definitions @@ -151,7 +149,7 @@ def get_apksigner_certs(self) -> str: apksigner = Apksigner() return apksigner.get_certs(self.path) - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_app_icon(self) -> bytes | None: manifest = self.get_manifest() if manifest.application is None: diff --git a/src/launchpad/artifacts/android/zipped_aab.py b/src/launchpad/artifacts/android/zipped_aab.py index 0ad05468..bebdaf2d 100644 --- a/src/launchpad/artifacts/android/zipped_aab.py +++ b/src/launchpad/artifacts/android/zipped_aab.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Callable -import sentry_sdk +import sentry_sdk.traces from launchpad.parsers.android.dex.dex_mapping import DexMapping @@ -25,7 +25,7 @@ def __init__(self, path: Path, cleanup: None | Callable[[], None] = None) -> Non def get_manifest(self) -> AndroidManifest: return self.get_aab().get_manifest() - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_aab(self) -> AAB: if self._aab is not None: return self._aab diff --git a/src/launchpad/artifacts/android/zipped_apk.py b/src/launchpad/artifacts/android/zipped_apk.py index 9b799b8b..413725ba 100644 --- a/src/launchpad/artifacts/android/zipped_apk.py +++ b/src/launchpad/artifacts/android/zipped_apk.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Callable -import sentry_sdk +import sentry_sdk.traces from launchpad.parsers.android.dex.dex_mapping import DexMapping @@ -24,7 +24,7 @@ def __init__(self, path: Path, cleanup: None | Callable[[], None] = None) -> Non def get_manifest(self) -> AndroidManifest: return self.get_primary_apk().get_manifest() - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_primary_apk(self) -> APK: if self._primary_apk is not None: return self._primary_apk diff --git a/src/launchpad/artifacts/apple/zipped_xcarchive.py b/src/launchpad/artifacts/apple/zipped_xcarchive.py index d531ed7a..af33bf54 100644 --- a/src/launchpad/artifacts/apple/zipped_xcarchive.py +++ b/src/launchpad/artifacts/apple/zipped_xcarchive.py @@ -10,7 +10,7 @@ from typing import Any, List, NamedTuple import lief -import sentry_sdk +import sentry_sdk.traces from launchpad.parsers.apple.crushed_png import decode_crushed_png from launchpad.utils.logging import get_logger @@ -77,7 +77,7 @@ def __init__(self, path: Path) -> None: def get_extract_dir(self) -> SafeDirectory: return self._extract_dir - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_plist(self) -> dict[str, Any]: if self._plist is not None: return self._plist @@ -94,7 +94,7 @@ def get_plist(self) -> dict[str, Any]: except Exception as e: raise RuntimeError("Failed to parse Info.plist") from e - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_archive_plist(self) -> dict[str, Any] | None: """Get the archive-level Info.plist (not the app bundle's Info.plist).""" if self._archive_plist is not None: @@ -118,7 +118,7 @@ def get_archive_plist(self) -> dict[str, Any] | None: logger.debug(f"Failed to parse archive Info.plist at {plist_path}", exc_info=True) return None - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_app_icon(self) -> bytes | None: """Get the primary app icon, decoded from crushed PNG format.""" icon_info = self.get_icon_info() @@ -173,7 +173,7 @@ def icon_priority(path: Path) -> tuple[int, int]: logger.warning(f"No icon files found for CFBundleIconFiles: {icon_info.primary_icon_files}") return None - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_icon_info(self) -> AppIconInfo: """Extract icon information from Info.plist.""" plist = self.get_plist() @@ -201,7 +201,7 @@ def get_icon_info(self) -> AppIconInfo: return AppIconInfo(primary_icon_name, primary_icon_files, alternate_icon_names) - @sentry_sdk.trace + @sentry_sdk.traces.trace def generate_ipa(self, output_path: Path): """Generate an IPA file @@ -254,7 +254,7 @@ def generate_ipa(self, output_path: Path): except FileNotFoundError: raise RuntimeError("zip command not found. This tool is required for IPA generation.") - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_provisioning_profile(self) -> dict[str, Any] | None: if self._provisioning_profile is not None: return self._provisioning_profile @@ -278,7 +278,7 @@ def get_provisioning_profile(self) -> dict[str, Any] | None: logger.debug(f"No embedded.mobileprovision found at {mobileprovision_path}") return None - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_binary_path(self) -> Path | None: app_bundle_path = self.get_app_bundle_path() plist = self.get_plist() @@ -288,7 +288,7 @@ def get_binary_path(self) -> Path | None: return app_bundle_path.resolve(executable_name) - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_app_bundle_path(self) -> SafeDirectory: """Get the path to the .app bundle.""" if self._app_bundle_path is not None: @@ -302,12 +302,12 @@ def get_app_bundle_path(self) -> SafeDirectory: raise FileNotFoundError(f"No .app bundle found in {self._extract_dir}") - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_main_binary_uuid(self) -> str | None: main_binary_path = self._get_main_binary_path() return self._extract_binary_uuid(main_binary_path) - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_all_binary_paths(self) -> List[BinaryInfo]: """Find all binaries in the app bundle and their corresponding dSYM files. @@ -366,7 +366,7 @@ def get_all_binary_paths(self) -> List[BinaryInfo]: return binaries - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_asset_catalog_details(self, relative_path: Path) -> List[AssetCatalogElement]: """Get the details of an asset catalog file (Assets.car) by returning the parsed JSON from ParsedAssets.""" @@ -580,7 +580,7 @@ def _extract_binary_uuid(self, binary_path: Path) -> str | None: logger.exception(f"Failed to extract UUID from binary {binary_path}") return None - @sentry_sdk.trace + @sentry_sdk.traces.trace def _find_dsym_files(self) -> dict[str, DsymInfo]: """Find all dSYM bundles and map them by binary UUID. diff --git a/src/launchpad/artifacts/artifact_factory.py b/src/launchpad/artifacts/artifact_factory.py index 38b65f75..3ffb02d6 100644 --- a/src/launchpad/artifacts/artifact_factory.py +++ b/src/launchpad/artifacts/artifact_factory.py @@ -3,7 +3,7 @@ from pathlib import Path from zipfile import BadZipFile, ZipFile -import sentry_sdk +import sentry_sdk.traces from .android.aab import AAB from .android.apk import APK @@ -20,7 +20,7 @@ class ArtifactFactory: """Factory for creating artifacts from paths.""" @staticmethod - @sentry_sdk.trace + @sentry_sdk.traces.trace def from_path(path: Path) -> Artifact: """Create appropriate Artifact from file path. diff --git a/src/launchpad/parsers/apple/linkmap_parser.py b/src/launchpad/parsers/apple/linkmap_parser.py index 62f4376a..e9723cfc 100644 --- a/src/launchpad/parsers/apple/linkmap_parser.py +++ b/src/launchpad/parsers/apple/linkmap_parser.py @@ -6,7 +6,7 @@ from dataclasses import dataclass, field from pathlib import Path -import sentry_sdk +import sentry_sdk.traces from ...utils.logging import get_logger @@ -96,7 +96,7 @@ def symbolicate(self, addr: int) -> LinkmapSymbol | None: return None - @sentry_sdk.trace + @sentry_sdk.traces.trace def _parse(self, contents: str) -> None: lines = [line for line in contents.split("\n") if line.strip()] diff --git a/src/launchpad/parsers/apple/macho_parser.py b/src/launchpad/parsers/apple/macho_parser.py index 5d71f8a1..92e11767 100644 --- a/src/launchpad/parsers/apple/macho_parser.py +++ b/src/launchpad/parsers/apple/macho_parser.py @@ -9,7 +9,7 @@ from typing import Dict, List import lief -import sentry_sdk +import sentry_sdk.traces from launchpad.size.models.apple import LinkEditInfo @@ -66,27 +66,27 @@ def is_macho_binary(file_path: Path) -> bool: except Exception: return False - @sentry_sdk.trace + @sentry_sdk.traces.trace def extract_architectures(self) -> List[str]: """Extract CPU architectures from the binary.""" return [str(self.binary.header.cpu_type)] - @sentry_sdk.trace + @sentry_sdk.traces.trace def extract_linked_libraries(self) -> List[str]: """Extract linked dynamic libraries from the binary.""" return [str(lib.name) for lib in self.binary.libraries] - @sentry_sdk.trace + @sentry_sdk.traces.trace def extract_sections(self) -> Dict[str, int]: """Extract binary sections and their sizes.""" return {str(section.name): section.size for section in self.binary.sections} - @sentry_sdk.trace + @sentry_sdk.traces.trace def extract_swift_sections(self) -> List[lief.Section]: """Get Swift sections from the binary.""" return [section for section in self.binary.sections if "swift" in str(section.name).lower()] - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_header_size(self) -> int: """Get the size of the Mach-O header.""" # Mach-O header is typically at the beginning @@ -95,13 +95,13 @@ def get_header_size(self) -> int: # TODO(EME-425): implement proper header size, seems hard to do with LIEF return header_size - @sentry_sdk.trace + @sentry_sdk.traces.trace def _cpu_type_to_string(self, cpu_type: int) -> str | None: """Convert LIEF CPU type to string representation.""" result = CPU_TYPE_NAMES.get(cpu_type) return result.lower() if result else None - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_section_bytes_at_offset(self, section_name: str, offset: int, size: int) -> bytes | None: """Get specific bytes from a section at a given offset. @@ -128,7 +128,7 @@ def get_section_bytes_at_offset(self, section_name: str, offset: int, size: int) logger.exception(f"Failed to get section bytes at offset for {section_name}") return None - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_section_bytes(self, section_name: str) -> bytes | None: """Get raw bytes content of a specific section. @@ -155,7 +155,7 @@ def get_section_bytes(self, section_name: str) -> bytes | None: logger.exception(f"Failed to get section content for {section_name}") return None - @sentry_sdk.trace + @sentry_sdk.traces.trace def is_encrypted(self) -> bool: """Check if the Mach-O binary is encrypted. @@ -173,12 +173,12 @@ def is_encrypted(self) -> bool: logger.exception("Failed to check encryption status") return False - @sentry_sdk.trace + @sentry_sdk.traces.trace def parse_swift_protocol_conformances(self) -> List[str]: """Parse the Swift protocol section.""" return SwiftProtocolParser(self.binary, self).parse_swift_protocol_conformances() - @sentry_sdk.trace + @sentry_sdk.traces.trace def read_indirect_pointer(self, offset: int) -> tuple[int, int]: """Read an indirect pointer from the binary. @@ -206,12 +206,12 @@ def read_indirect_pointer(self, offset: int) -> tuple[int, int]: else: return (vm_address + indirect_offset, 4) # Consumed 4 bytes - @sentry_sdk.trace + @sentry_sdk.traces.trace def parse_code_signature(self) -> CodeSignInformation | None: """Parse the code signature information from the binary.""" return CodeSignatureParser(self.binary, self).parse_code_signature() - @sentry_sdk.trace + @sentry_sdk.traces.trace def get_imported_symbols(self) -> List[str]: if self._imported_symbols_cache is not None: return self._imported_symbols_cache @@ -221,7 +221,7 @@ def get_imported_symbols(self) -> List[str]: return self._imported_symbols_cache - @sentry_sdk.trace + @sentry_sdk.traces.trace def parse_objc_method_names(self) -> List[str]: """Parse Objective-C method names from the __objc_methname section. @@ -250,7 +250,7 @@ def parse_objc_method_names(self) -> List[str]: logger.exception("Failed to parse Objective-C method names") return [] - @sentry_sdk.trace + @sentry_sdk.traces.trace def has_swift_imageinfo(self) -> bool: """Check if the binary has Swift image info with non-zero Swift version. @@ -278,7 +278,7 @@ def has_swift_imageinfo(self) -> bool: logger.exception("Could not parse __objc_imageinfo") return False - @sentry_sdk.trace + @sentry_sdk.traces.trace def static_inits(self) -> List[lief.Symbol | str]: init_sec = self.get_section_bytes("__mod_init_func") if not init_sec: @@ -313,7 +313,7 @@ def find_symbol(addr: int) -> lief.Symbol | None: return symbols - @sentry_sdk.trace + @sentry_sdk.traces.trace def extract_linkedit_info(self) -> LinkEditInfo: """Extract all __LINKEDIT segment component sizes from load commands.""" symbol_table_size = 0 diff --git a/src/launchpad/sentry_sdk_init.py b/src/launchpad/sentry_sdk_init.py index 2142bd4a..7626e870 100644 --- a/src/launchpad/sentry_sdk_init.py +++ b/src/launchpad/sentry_sdk_init.py @@ -52,6 +52,7 @@ def initialize_sentry_sdk(config: SentryConfig | None = None) -> None: enable_logs=True, environment=config.region, traces_sample_rate=1.0, # Low volume service, capture all traces for now + trace_lifecycle="stream", ) logger.info(f"Sentry SDK initialized for environment: {config.region}") diff --git a/src/launchpad/size/analyzers/android.py b/src/launchpad/size/analyzers/android.py index 9800fb76..7390525c 100644 --- a/src/launchpad/size/analyzers/android.py +++ b/src/launchpad/size/analyzers/android.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone -import sentry_sdk +import sentry_sdk.traces from launchpad.artifacts.android.aab import AAB from launchpad.artifacts.android.apk import APK @@ -68,20 +68,22 @@ def preprocess(self, artifact: AndroidArtifact) -> AndroidAppInfo: return self.app_info - @sentry_sdk.trace + @sentry_sdk.traces.trace def analyze(self, artifact: AndroidArtifact) -> AndroidAnalysisResults: start_time = time.time() # Use preprocessed app info if available, otherwise extract it if not self.app_info: - with sentry_sdk.start_span(op="analyze", description="android.analyze.preprocess"): + with sentry_sdk.traces.start_span(name="android.analyze.preprocess", attributes={"sentry.op": "analyze"}): self.app_info = self.preprocess(artifact) app_info = self.app_info apks: list[APK] = [] # Split AAB into APKs, or use the APK directly - with sentry_sdk.start_span(op="analyze", description="android.analyze.get_primary_apks") as span: - span.set_data("artifact.type", type(artifact).__name__) + with sentry_sdk.traces.start_span( + name="android.analyze.get_primary_apks", attributes={"sentry.op": "analyze"} + ) as span: + span.set_attribute("artifact.type", type(artifact).__name__) if isinstance(artifact, AAB): apks = artifact.get_primary_apks() elif isinstance(artifact, ZippedAAB): @@ -92,20 +94,22 @@ def analyze(self, artifact: AndroidArtifact) -> AndroidAnalysisResults: apks.append(artifact) else: raise ValueError(f"Unsupported artifact type: {type(artifact)}") - span.set_data("apk.count", len(apks)) + span.set_attribute("apk.count", len(apks)) logger.debug("Found %d APKs", len(apks)) - with sentry_sdk.start_span(op="analyze", description="android.analyze.file_analysis"): + with sentry_sdk.traces.start_span(name="android.analyze.file_analysis", attributes={"sentry.op": "analyze"}): file_analysis = self._get_file_analysis(apks) - with sentry_sdk.start_span(op="analyze", description="android.analyze.class_definitions"): + with sentry_sdk.traces.start_span( + name="android.analyze.class_definitions", attributes={"sentry.op": "analyze"} + ): class_definitions = self._get_class_definitions(apks) - with sentry_sdk.start_span(op="analyze", description="android.analyze.hermes_reports"): + with sentry_sdk.traces.start_span(name="android.analyze.hermes_reports", attributes={"sentry.op": "analyze"}): hermes_reports = self._get_hermes_reports(apks) # Calculate download and install sizes logger.debug("Calculating APK download and install sizes") - with sentry_sdk.start_span(op="analyze", description="android.analyze.apk_sizes"): + with sentry_sdk.traces.start_span(name="android.analyze.apk_sizes", attributes={"sentry.op": "analyze"}): download_size, install_size = self._calculate_apk_sizes(apks) # Generate insights BEFORE treemap so we can tag nodes with flagged insights @@ -142,7 +146,7 @@ def analyze(self, artifact: AndroidArtifact) -> AndroidAnalysisResults: ) logger.debug("Building file treemap") - with sentry_sdk.start_span(op="analyze", description="android.analyze.treemap"): + with sentry_sdk.traces.start_span(name="android.analyze.treemap", attributes={"sentry.op": "analyze"}): treemap = treemap_builder.build_file_treemap(file_analysis) analysis_duration = time.time() - start_time diff --git a/src/launchpad/size/analyzers/apple.py b/src/launchpad/size/analyzers/apple.py index 85c0f0b6..02a1aeb3 100644 --- a/src/launchpad/size/analyzers/apple.py +++ b/src/launchpad/size/analyzers/apple.py @@ -14,10 +14,9 @@ from typing import Any, Callable, Dict, List, NamedTuple, Tuple import lief -import sentry_sdk +import sentry_sdk.traces from cryptography import x509 -from sentry_sdk.utils import qualname_from_function from launchpad.artifacts.apple.zipped_xcarchive import BinaryInfo, ZippedXCArchive from launchpad.artifacts.artifact import AppleArtifact @@ -100,22 +99,18 @@ def _binary_worker_init(context: _WorkerContext) -> None: bind_artifact_id(context.artifact_id) if context.sentry_config is not None: initialize_sentry_sdk(context.sentry_config) - sentry_sdk.continue_trace(context.trace_headers) + sentry_sdk.traces.continue_trace(context.trace_headers) -def _analyze_and_flush(analyze: Callable[[BinaryInfo], _TimedBinary], binary_info: BinaryInfo) -> _TimedBinary: +def _analyze_and_flush( + analyze: Callable[[BinaryInfo], MachOBinaryAnalysis | None], binary_info: BinaryInfo +) -> MachOBinaryAnalysis | None: try: return analyze(binary_info) finally: sentry_sdk.flush(timeout=2) -class _TimedBinary(NamedTuple): - binary: MachOBinaryAnalysis | None - started_at: float - finished_at: float - - class _BinaryMetadata(NamedTuple): executable_size: int architecture_slices: List[ArchitectureSlice] @@ -158,7 +153,7 @@ def __init__( self.skip_insights = skip_insights self.app_info: AppleAppInfo | None = None - @sentry_sdk.trace + @sentry_sdk.traces.trace def preprocess(self, artifact: AppleArtifact) -> AppleAppInfo: if not isinstance(artifact, ZippedXCArchive): raise NotImplementedError(f"Only ZippedXCArchive artifacts are supported, got {type(artifact)}") @@ -166,7 +161,7 @@ def preprocess(self, artifact: AppleArtifact) -> AppleAppInfo: self.app_info = self._extract_app_info(artifact) return self.app_info - @sentry_sdk.trace + @sentry_sdk.traces.trace def analyze(self, artifact: AppleArtifact) -> AppleAnalysisResults: """Analyze an Apple app bundle. @@ -240,12 +235,10 @@ def analyze(self, artifact: AppleArtifact) -> AppleAnalysisResults: logger.debug("Analyzing binaries in-process") results = [analyze(binary_info) for binary_info in binaries] - for binary_info, timed in zip(binaries, results): - if workers > 0: - self._record_binary_span(binary_info, timed) - if timed.binary is not None: - binary_analysis.append(timed.binary) - binary_analysis_map[str(binary_info.path.relative_to(app_bundle_path))] = timed.binary + for binary_info, binary in zip(binaries, results): + if binary is not None: + binary_analysis.append(binary) + binary_analysis_map[str(binary_info.path.relative_to(app_bundle_path))] = binary logger.info( "size.apple.binary_analysis_phase_completed", @@ -362,7 +355,7 @@ def parse_plist_date(self, date_value: str | datetime | None) -> str | None: return date_value.isoformat() - @sentry_sdk.trace + @sentry_sdk.traces.trace def _extract_app_info(self, xcarchive: ZippedXCArchive) -> AppleAppInfo: """Extract basic app information. @@ -515,7 +508,7 @@ def _extract_certificate_expiration_date(self, provisioning_profile: dict[str, A def _generate_insight_with_tracing( self, insight_class: type, insights_input: InsightsInput, insight_name: str ) -> Any: - with sentry_sdk.start_span(op="insight", description=f"apple.insights.{insight_name}"): + with sentry_sdk.traces.start_span(name=f"apple.insights.{insight_name}", attributes={"sentry.op": "insight"}): started = time.monotonic() result = insight_class().generate(insights_input) logger.info( @@ -529,24 +522,14 @@ def _binary_analysis_worker_count(self, num_binaries: int) -> int: def _analyze_binary_logged( self, binary_info: BinaryInfo, app_bundle_path: Path, extract_dir: SafeDirectory - ) -> _TimedBinary: + ) -> MachOBinaryAnalysis | None: self._log_binary_started(binary_info, app_bundle_path, extract_dir) - started_at = time.time() start = time.monotonic() binary = self._analyze_binary(binary_info, app_bundle_path) elapsed_s = time.monotonic() - start if binary is not None: self._log_binary_completed(binary_info, binary, elapsed_s) - return _TimedBinary(binary, started_at, started_at + elapsed_s) - - def _record_binary_span(self, binary_info: BinaryInfo, timed: _TimedBinary) -> None: - span = sentry_sdk.start_span( - op="function", - name=qualname_from_function(self._analyze_binary), - start_timestamp=timed.started_at, - ) - span.set_data("binary_name", binary_info.name) - span.finish(end_timestamp=timed.finished_at) + return binary def _log_binary_started(self, binary_info: BinaryInfo, app_bundle_path: Path, extract_dir: SafeDirectory) -> None: logger.info( @@ -583,7 +566,7 @@ def _log_binary_completed(self, binary_info: BinaryInfo, binary: MachOBinaryAnal }, ) - @sentry_sdk.trace + @sentry_sdk.traces.trace def _analyze_binary( self, binary_info: BinaryInfo, @@ -594,6 +577,9 @@ def _analyze_binary( dwarf_binary_path = binary_info.dsym_path is_main_binary = binary_info.is_main_binary + if (span := sentry_sdk.traces.get_current_span()) is not None: + span.set_attribute("binary_name", binary_info.name) + if not binary_path.exists(): logger.warning(f"Binary not found: {binary_path}") return None @@ -643,7 +629,7 @@ def _analyze_binary( ) if binary_info.relocations_path and os.getenv("LAUNCHPAD_ENV") == "development": - with sentry_sdk.start_span(op="parse", description="dwarf_relocations.parse"): + with sentry_sdk.traces.start_span(name="dwarf_relocations.parse", attributes={"sentry.op": "parse"}): dwarf_relocations = DwarfRelocationsParser.parse(binary_info.relocations_path) if dwarf_relocations: logger.debug( @@ -707,15 +693,19 @@ def _extract_binary_metadata(self, binary_path: Path) -> _BinaryMetadata: strippable_symbols_size=strippable_symbols_size, ) - @sentry_sdk.trace + @sentry_sdk.traces.trace def _check_strip_symbols_removal(self, binary_path: Path, binary: lief.MachO.Binary) -> int: """Test actual symbol removal using AppleStrip to get real size savings.""" try: - with sentry_sdk.start_span(op="measure", description="strip_symbols.get_original_size"): + with sentry_sdk.traces.start_span( + name="strip_symbols.get_original_size", attributes={"sentry.op": "measure"} + ): original_size = binary_path.stat().st_size # Create a temporary file for the stripped output - with sentry_sdk.start_span(op="measure", description="strip_symbols.create_temp_output"): + with sentry_sdk.traces.start_span( + name="strip_symbols.create_temp_output", attributes={"sentry.op": "measure"} + ): temp_file = tempfile.NamedTemporaryFile(suffix=".stripped", delete=False) temp_output_path = Path(temp_file.name) temp_file.close() @@ -728,7 +718,9 @@ def _check_strip_symbols_removal(self, binary_path: Path, binary: lief.MachO.Bin else: strip_flags = ["-S", "-T", "-x"] - with sentry_sdk.start_span(op="process", description="strip_symbols.strip_binary"): + with sentry_sdk.traces.start_span( + name="strip_symbols.strip_binary", attributes={"sentry.op": "process"} + ): apple_strip = AppleStrip() result = apple_strip.strip( input_file=binary_path, @@ -740,7 +732,9 @@ def _check_strip_symbols_removal(self, binary_path: Path, binary: lief.MachO.Bin logger.error(f"Strip command failed for {binary_path.name} with return code {result.returncode}") actual_savings = 0 else: - with sentry_sdk.start_span(op="measure", description="strip_symbols.calculate_savings"): + with sentry_sdk.traces.start_span( + name="strip_symbols.calculate_savings", attributes={"sentry.op": "measure"} + ): stripped_size = temp_output_path.stat().st_size actual_savings = max(0, original_size - stripped_size) diff --git a/src/launchpad/size/symbols/cpp_aggregator.py b/src/launchpad/size/symbols/cpp_aggregator.py index 5124ba88..bbf2f9fa 100644 --- a/src/launchpad/size/symbols/cpp_aggregator.py +++ b/src/launchpad/size/symbols/cpp_aggregator.py @@ -7,7 +7,7 @@ from collections import defaultdict from typing import List, NamedTuple -import sentry_sdk +import sentry_sdk.traces from launchpad.size.symbols.macho_symbol_sizes import SymbolSize from launchpad.size.symbols.types import CppSymbolList, CppSymbolTypeGroup @@ -96,7 +96,7 @@ def _extract_namespace_and_function(mangled_name: str) -> CppNamespaceFunction: return CppNamespaceFunction(namespace=namespace, function_name=function_name) - @sentry_sdk.trace + @sentry_sdk.traces.trace def aggregate_symbols(self, symbol_sizes: CppSymbolList) -> List[CppSymbolTypeGroup]: buckets: dict[CppNamespaceFunction, list[SymbolSize]] = defaultdict(list) diff --git a/src/launchpad/size/symbols/objc_aggregator.py b/src/launchpad/size/symbols/objc_aggregator.py index f1e9244a..7dbae7f7 100644 --- a/src/launchpad/size/symbols/objc_aggregator.py +++ b/src/launchpad/size/symbols/objc_aggregator.py @@ -7,7 +7,7 @@ from collections import defaultdict from typing import List, NamedTuple -import sentry_sdk +import sentry_sdk.traces from launchpad.size.symbols.macho_symbol_sizes import SymbolSize from launchpad.size.symbols.types import ObjCSymbolList, ObjCSymbolTypeGroup @@ -93,7 +93,7 @@ def _class_from_metadata(cls, name: str) -> str: name = name.split(".", 1)[0] return name.strip("_") or "Unknown" - @sentry_sdk.trace + @sentry_sdk.traces.trace def aggregate_symbols(self, symbol_sizes: ObjCSymbolList) -> List[ObjCSymbolTypeGroup]: buckets: dict[ObjCSymbol, list[SymbolSize]] = defaultdict(list) diff --git a/src/launchpad/size/symbols/partitioner.py b/src/launchpad/size/symbols/partitioner.py index 150df910..9d61a0bd 100644 --- a/src/launchpad/size/symbols/partitioner.py +++ b/src/launchpad/size/symbols/partitioner.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import Callable, List, NamedTuple -import sentry_sdk +import sentry_sdk.traces from launchpad.size.symbols.cpp_aggregator import CppSymbolTypeAggregator from launchpad.size.symbols.macho_symbol_sizes import SymbolSize @@ -86,9 +86,11 @@ def _is_compiler_generated(mangled_name: str) -> bool: return False @classmethod - @sentry_sdk.trace + @sentry_sdk.traces.trace def from_symbol_sizes(cls, symbol_sizes: List[SymbolSize]) -> "SymbolInfo": - with sentry_sdk.start_span(op="partition_symbols", description="Partition symbols by language"): + with sentry_sdk.traces.start_span( + name="Partition symbols by language", attributes={"sentry.op": "partition_symbols"} + ): swift_symbols = SwiftSymbolList() objc_symbols = ObjCSymbolList() cpp_symbols = CppSymbolList() diff --git a/src/launchpad/size/symbols/swift_aggregator.py b/src/launchpad/size/symbols/swift_aggregator.py index f8dfdc7c..b88a72c0 100644 --- a/src/launchpad/size/symbols/swift_aggregator.py +++ b/src/launchpad/size/symbols/swift_aggregator.py @@ -4,7 +4,7 @@ from typing import NamedTuple -import sentry_sdk +import sentry_sdk.traces from launchpad.size.symbols.macho_symbol_sizes import SymbolSize from launchpad.size.symbols.types import SwiftSymbolList, SwiftSymbolTypeGroup @@ -38,16 +38,20 @@ def is_swift_symbol(mangled_name: str) -> bool: return True return False - @sentry_sdk.trace + @sentry_sdk.traces.trace def aggregate_symbols(self, symbol_sizes: SwiftSymbolList) -> list[SwiftSymbolTypeGroup]: # Demangle all Swift symbols - with sentry_sdk.start_span(op="demangle", description=f"cwl_demangle {len(symbol_sizes)} symbols"): + with sentry_sdk.traces.start_span( + name=f"cwl_demangle {len(symbol_sizes)} symbols", attributes={"sentry.op": "demangle"} + ): for symbol in symbol_sizes: self.demangler.add_name(symbol.mangled_name) demangled_results = self.demangler.demangle_all() # Group symbols by module/type - with sentry_sdk.start_span(op="aggregate", description=f"group {len(symbol_sizes)} symbols by module/type"): + with sentry_sdk.traces.start_span( + name=f"group {len(symbol_sizes)} symbols by module/type", attributes={"sentry.op": "aggregate"} + ): type_groups: dict[SwiftModuleType, list[SymbolSize]] = {} for symbol in symbol_sizes: diff --git a/src/launchpad/size/utils/file_analysis.py b/src/launchpad/size/utils/file_analysis.py index be3d4526..e817b9b0 100644 --- a/src/launchpad/size/utils/file_analysis.py +++ b/src/launchpad/size/utils/file_analysis.py @@ -5,7 +5,7 @@ from pathlib import Path, PurePosixPath from typing import Dict, List, Set, Tuple -import sentry_sdk +import sentry_sdk.traces from launchpad.artifacts.apple.zipped_xcarchive import ZippedXCArchive from launchpad.size.constants import APPLE_FILESYSTEM_BLOCK_SIZE @@ -18,7 +18,7 @@ OMITTED_LEAF_NAME = "__omitted__" -@sentry_sdk.trace +@sentry_sdk.traces.trace def analyze_apple_files( xcarchive: ZippedXCArchive, max_depth: int | None = 1000, @@ -270,7 +270,7 @@ def depth(p: str) -> int: return updated -@sentry_sdk.trace +@sentry_sdk.traces.trace def _analyze_asset_catalog(xcarchive: ZippedXCArchive, relative_path: Path) -> List[FileInfo]: """Analyze an asset catalog file into treemap children.""" catalog_details = xcarchive.get_asset_catalog_details(relative_path) @@ -301,7 +301,7 @@ def _analyze_asset_catalog(xcarchive: ZippedXCArchive, relative_path: Path) -> L return result -@sentry_sdk.trace +@sentry_sdk.traces.trace def _detect_file_type(file_path: Path) -> str: """Best-effort file type detection via `file` as a fallback.""" import subprocess diff --git a/tests/integration/size/test_apple_app_sizes.py b/tests/integration/size/test_apple_app_sizes.py index 082f8ab2..e2bfa0aa 100644 --- a/tests/integration/size/test_apple_app_sizes.py +++ b/tests/integration/size/test_apple_app_sizes.py @@ -15,8 +15,7 @@ from unittest.mock import patch import pytest -import sentry_sdk -import sentry_sdk.transport +import sentry_sdk.traces from sentry_sdk.envelope import Envelope @@ -59,8 +58,14 @@ def __enter__(self) -> _SentryIngestStub: def __exit__(self, *_exc: object) -> None: self._server.shutdown() - def transaction_count(self) -> int: - return sum(1 for e in self.envelopes for item in e.items if item.headers.get("type") == "transaction") + def span_items(self) -> list[dict[str, Any]]: + return [ + span + for envelope in self.envelopes + for item in envelope.items + if item.headers.get("type") == "span" + for span in json.loads(item.get_bytes())["items"] + ] def log_items(self) -> list[dict[str, Any]]: return [ @@ -72,17 +77,6 @@ def log_items(self) -> list[dict[str, Any]]: ] -class _CapturingTransport(sentry_sdk.transport.Transport): - def __init__(self) -> None: - super().__init__() - self.transactions: list[dict[str, Any]] = [] - - def capture_envelope(self, envelope: Envelope) -> None: - for item in envelope.items: - if item.headers.get("type") == "transaction": - self.transactions.append(json.loads(item.get_bytes())) - - def _log_suppressed_and_allowed(_: int) -> None: logging.getLogger("lief").info("noisy third-party") logging.getLogger("launchpad.size.analyzers.apple").info("allowed") @@ -250,9 +244,9 @@ def test_worker_logs_are_sent_to_sentry_under_parent_trace( monkeypatch.setenv("LAUNCHPAD_ENV", "integration") monkeypatch.setenv("SENTRY_DSN", stub.dsn) monkeypatch.setenv("SENTRY_REGION", "test") - sentry_sdk.init(dsn=stub.dsn, traces_sample_rate=1.0, enable_logs=True) + sentry_sdk.init(dsn=stub.dsn, traces_sample_rate=1.0, enable_logs=True, trace_lifecycle="stream") try: - with sentry_sdk.start_transaction(name="test") as transaction: + with sentry_sdk.traces.start_span(name="test") as span: AppleAppAnalyzer(skip_treemap=False, binary_analysis_workers=2).analyze( cast(AppleArtifact, ArtifactFactory.from_path(hackernews_xcarchive)) ) @@ -262,40 +256,49 @@ def test_worker_logs_are_sent_to_sentry_under_parent_trace( worker_logs = [item for item in stub.log_items() if item["body"] == "size.apple.binary_analysis_completed"] assert worker_logs - assert all(item["trace_id"] == transaction.trace_id for item in worker_logs) + assert all(item["trace_id"] == span.trace_id for item in worker_logs) assert all(item["attributes"]["process.pid"]["value"] != os.getpid() for item in worker_logs) - assert stub.transaction_count() == 1 - def test_parallel_binary_spans_reattach_to_parent_transaction( + def test_worker_binary_spans_are_streamed_under_parent_span( self, hackernews_xcarchive: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Workers can't report spans, so the parent rebuilds one per binary from worker - timestamps; they must land on the task transaction and reflect real concurrency.""" + """Workers stream their own spans; each binary's span must join the parent trace, + hang off a span from the parent process and reflect real concurrency.""" ctx = mp.get_context("forkserver") monkeypatch.setattr(apple, "ProcessPoolExecutor", functools.partial(ProcessPoolExecutor, mp_context=ctx)) - monkeypatch.setenv("LAUNCHPAD_ENV", "test") - transport = _CapturingTransport() - sentry_sdk.init(dsn="https://key@example.invalid/1", transport=transport, traces_sample_rate=1.0) - try: - with sentry_sdk.start_transaction(name="test"): - artifact = cast(AppleArtifact, ArtifactFactory.from_path(hackernews_xcarchive)) - binary_count = len(artifact.get_all_binary_paths()) - AppleAppAnalyzer(skip_treemap=False, binary_analysis_workers=2).analyze(artifact) - sentry_sdk.flush() - finally: - sentry_sdk.get_global_scope().set_client(None) - - spans = [ - s - for t in transport.transactions - for s in t["spans"] - if s["op"] == "function" and s["description"].endswith("AppleAppAnalyzer._analyze_binary") - ] - assert len(spans) == binary_count - assert {s["data"]["binary_name"] for s in spans} == {b.name for b in artifact.get_all_binary_paths()} - assert all(s["timestamp"] > s["start_timestamp"] for s in spans) + with _SentryIngestStub() as stub: + monkeypatch.setenv("LAUNCHPAD_ENV", "integration") + monkeypatch.setenv("SENTRY_DSN", stub.dsn) + monkeypatch.setenv("SENTRY_REGION", "test") + sentry_sdk.init(dsn=stub.dsn, traces_sample_rate=1.0, trace_lifecycle="stream") + try: + with sentry_sdk.traces.start_span(name="test") as span: + artifact = cast(AppleArtifact, ArtifactFactory.from_path(hackernews_xcarchive)) + AppleAppAnalyzer(skip_treemap=False, binary_analysis_workers=2).analyze(artifact) + sentry_sdk.flush() + finally: + sentry_sdk.get_global_scope().set_client(None) + + spans = stub.span_items() + parent_ids = {s["span_id"]: s.get("parent_span_id") for s in spans} + + def descends_from_test_span(span_id: str | None) -> bool: + while span_id is not None and span_id != span.span_id: + span_id = parent_ids.get(span_id) + return span_id == span.span_id + + binary_spans = [s for s in spans if s["name"].endswith("AppleAppAnalyzer._analyze_binary")] + assert {s["attributes"]["binary_name"]["value"] for s in binary_spans} == { + b.name for b in artifact.get_all_binary_paths() + } + assert all(s["trace_id"] == span.trace_id for s in binary_spans) + # A segment means the span was started in a worker process, continuing the propagated trace + assert all(s["is_segment"] for s in binary_spans) + assert all(descends_from_test_span(s["parent_span_id"]) for s in binary_spans) assert any( - a is not b and a["start_timestamp"] < b["start_timestamp"] < a["timestamp"] for a in spans for b in spans + a is not b and a["start_timestamp"] < b["start_timestamp"] < a["end_timestamp"] + for a in binary_spans + for b in binary_spans ) def test_apple_app_sizes(self, hackernews_xcarchive: Path) -> None: diff --git a/uv.lock b/uv.lock index ba6ec993..d5969a2a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.14" [[package]] @@ -856,7 +856,7 @@ requires-dist = [ { name = "rich", specifier = ">=14.1.0" }, { name = "sentry-arroyo", specifier = ">=2.47.0" }, { name = "sentry-options", specifier = "==1.2.10" }, - { name = "sentry-sdk", specifier = ">=2.36.0" }, + { name = "sentry-sdk", specifier = ">=2.70.0" }, { name = "sortedcontainers", specifier = ">=2.4.0" }, { name = "taskbroker-client", specifier = ">=0.20.26,<1" }, { name = "typing-extensions", specifier = ">=4.15.0" },