diff --git a/.github/workflows/flutter-symbols.yml b/.github/workflows/flutter-symbols.yml new file mode 100644 index 0000000000..057240d94a --- /dev/null +++ b/.github/workflows/flutter-symbols.yml @@ -0,0 +1,58 @@ +name: Flutter symbols collection +on: + schedule: + # Run once an hour. It takes just a couple of minutes because of status caching. + - cron: "10 * * * *" + workflow_dispatch: + inputs: + flutter_version: + description: Flutter version, can be either a specific version (3.17.0) or a wildcard (3.2.*) + required: false + type: string + default: "3.*.*" + +defaults: + run: + working-directory: scripts/flutter_symbol_collector + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: dart-lang/setup-dart@6a218f2413a3e78e9087f638a238f6b40893203d # pin@v1 + + - run: dart pub get + + - run: dart test + + run: + needs: [test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: dart-lang/setup-dart@6a218f2413a3e78e9087f638a238f6b40893203d # pin@v1 + + - run: dart pub get + + - name: Download status cache of previously processed files + run: | + gh run download --name 'flutter-symbol-collector-database' --dir .cache + grep -r "" .cache + continue-on-error: true + env: + GITHUB_TOKEN: ${{ github.token }} + + - run: dart run bin/flutter_symbol_collector.dart --version=${{ inputs.flutter_version || '3.*.*' }} + timeout-minutes: 300 + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: Upload updated status cache of processed files + uses: actions/upload-artifact@v3 + if: always() + with: + name: flutter-symbol-collector-database + path: scripts/flutter_symbol_collector/.cache diff --git a/.github/workflows/update-deps.yml b/.github/workflows/update-deps.yml index 953c63cf49..d7eaf21a58 100644 --- a/.github/workflows/update-deps.yml +++ b/.github/workflows/update-deps.yml @@ -35,3 +35,12 @@ jobs: changelog-entry: false secrets: api-token: ${{ secrets.CI_DEPLOY_KEY }} + + symbol-collector: + uses: getsentry/github-workflows/.github/workflows/updater.yml@v2 + with: + path: scripts/update-symbol-collector.sh + name: Symbol collector CLI + changelog-entry: false + secrets: + api-token: ${{ secrets.CI_DEPLOY_KEY }} diff --git a/CHANGELOG.md b/CHANGELOG.md index d6466d82fa..e3507770f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Enhancements - Log warning if both tracesSampleRate and tracesSampler are set ([#1701](https://github.com/getsentry/sentry-dart/pull/1701)) +- Better Flutter framework stack traces - we now collect Flutter framework debug symbols for iOS, macOS and Android automatically on the Sentry server ([#1673](https://github.com/getsentry/sentry-dart/pull/1673)) ### Features diff --git a/scripts/flutter_symbol_collector/.gitignore b/scripts/flutter_symbol_collector/.gitignore new file mode 100644 index 0000000000..d0e14b73aa --- /dev/null +++ b/scripts/flutter_symbol_collector/.gitignore @@ -0,0 +1,6 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +.temp +.cache diff --git a/scripts/flutter_symbol_collector/README.md b/scripts/flutter_symbol_collector/README.md new file mode 100644 index 0000000000..55c60bca27 --- /dev/null +++ b/scripts/flutter_symbol_collector/README.md @@ -0,0 +1,4 @@ +# Flutter symbol collector + +This is an internal tool to collect Flutter debug symbols and upload them to Sentry. +This application is not intended for public usage - we're uploading the symbols in CI automatically so you don't have to. diff --git a/scripts/flutter_symbol_collector/analysis_options.yaml b/scripts/flutter_symbol_collector/analysis_options.yaml new file mode 100644 index 0000000000..9fe3182f8d --- /dev/null +++ b/scripts/flutter_symbol_collector/analysis_options.yaml @@ -0,0 +1,7 @@ +include: package:lints/recommended.yaml + +linter: + rules: + prefer_relative_imports: true + unnecessary_brace_in_string_interps: true + unawaited_futures: true diff --git a/scripts/flutter_symbol_collector/bin/flutter_symbol_collector.dart b/scripts/flutter_symbol_collector/bin/flutter_symbol_collector.dart new file mode 100644 index 0000000000..424134eea7 --- /dev/null +++ b/scripts/flutter_symbol_collector/bin/flutter_symbol_collector.dart @@ -0,0 +1,96 @@ +import 'package:args/args.dart'; +import 'package:file/local.dart'; +import 'package:flutter_symbol_collector/flutter_symbol_collector.dart'; +import 'package:github/github.dart'; +import 'package:logging/logging.dart'; + +const githubToken = String.fromEnvironment('GITHUB_TOKEN'); +final githubAuth = githubToken.isEmpty + ? Authentication.anonymous() + : Authentication.withToken(githubToken); +final source = FlutterSymbolSource(githubAuth: githubAuth); +final fs = LocalFileSystem(); +final tempDir = fs.currentDirectory.childDirectory('.temp'); +final stateCache = + DirectoryStatusCache(fs.currentDirectory.childDirectory('.cache')); +late final SymbolCollectorCli collector; + +void main(List arguments) async { + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + print('${record.level.name}: ${record.time}: ${record.message}' + '${record.error == null ? '' : ': ${record.error}'}'); + }); + + final parser = ArgParser()..addOption('version', defaultsTo: ''); + final args = parser.parse(arguments); + final argVersion = args['version'] as String; + + collector = await SymbolCollectorCli.setup(tempDir); + + // If a specific version was given, run just for this version. + if (argVersion.isNotEmpty && + !argVersion.contains('*') && + argVersion.split('.').length == 3) { + Logger.root.info('Running for a single flutter version: $argVersion'); + await processFlutterVersion(FlutterVersion(argVersion)); + } else { + // Otherwise, walk all the versions and run for the matching ones. + final versionRegex = RegExp(argVersion.isEmpty + ? '.*' + : '^${argVersion.replaceAll('.', '\\.').replaceAll('*', '.+')}\$'); + Logger.root.info('Running for all Flutter versions matching $versionRegex'); + final versions = await source + .listFlutterVersions() + .where((v) => !v.isPreRelease) + .where((v) => versionRegex.hasMatch(v.tagName)) + .toList(); + Logger.root.info( + 'Found ${versions.length} Flutter versions matching $versionRegex'); + for (var version in versions) { + await processFlutterVersion(version); + } + } +} + +Future processFlutterVersion(FlutterVersion version) async { + if (bool.hasEnvironment('CI')) { + print('::group::Processing Flutter ${version.tagName}'); + } + Logger.root.info('Processing Flutter ${version.tagName}'); + Logger.root.info('Engine version: ${await version.engineVersion}'); + + final archives = await source.listSymbolArchives(version); + final dir = tempDir.childDirectory(version.tagName); + for (final archive in archives) { + final status = await stateCache.getStatus(archive); + if (status == SymbolArchiveStatus.success) { + Logger.root + .info('Skipping ${archive.path} - already processed successfully'); + continue; + } + + final archiveDir = dir.childDirectory(archive.platform.operatingSystem); + try { + if (await source.downloadAndExtractTo(archiveDir, archive.path)) { + if (await collector.upload(archiveDir, archive.platform, version)) { + await stateCache.setStatus(archive, SymbolArchiveStatus.success); + continue; + } + } + await stateCache.setStatus(archive, SymbolArchiveStatus.error); + } finally { + if (await archiveDir.exists()) { + await archiveDir.delete(recursive: true); + } + } + } + + if (await dir.exists()) { + await dir.delete(recursive: true); + } + + if (bool.hasEnvironment('CI')) { + print('::endgroup::'); + } +} diff --git a/scripts/flutter_symbol_collector/lib/flutter_symbol_collector.dart b/scripts/flutter_symbol_collector/lib/flutter_symbol_collector.dart new file mode 100644 index 0000000000..a972b43f75 --- /dev/null +++ b/scripts/flutter_symbol_collector/lib/flutter_symbol_collector.dart @@ -0,0 +1,5 @@ +export 'src/flutter_symbol_source.dart'; +export 'src/flutter_version.dart'; +export 'src/symbol_collector_cli.dart'; +export 'src/status_cache.dart'; +export 'src/symbol_archive.dart'; diff --git a/scripts/flutter_symbol_collector/lib/src/flutter_symbol_resolver.dart b/scripts/flutter_symbol_collector/lib/src/flutter_symbol_resolver.dart new file mode 100644 index 0000000000..93d9b7e02f --- /dev/null +++ b/scripts/flutter_symbol_collector/lib/src/flutter_symbol_resolver.dart @@ -0,0 +1,73 @@ +import 'package:gcloud/storage.dart'; +import 'package:platform/platform.dart'; + +import 'symbol_archive.dart'; + +abstract class FlutterSymbolResolver { + final String _prefix; + final Bucket _bucket; + final _resolvedFiles = List.empty(growable: true); + Platform get platform; + + FlutterSymbolResolver(this._bucket, String prefix) + : _prefix = prefix.endsWith('/') + ? prefix.substring(0, prefix.length - 1) + : prefix; + + Future tryResolve(String path) async { + path = '$_prefix/$path'; + final matches = await _bucket + .list(prefix: path) + .where((v) => v.isObject) + .where((v) => v.name == path) // because it's a prefix search + .map((v) => v.name) + .toList(); + if (matches.isNotEmpty) { + _resolvedFiles.add(SymbolArchive(matches.single, platform)); + } + } + + Future> listArchives(); +} + +class IosSymbolResolver extends FlutterSymbolResolver { + IosSymbolResolver(super.bucket, super.prefix); + + @override + final platform = FakePlatform(operatingSystem: Platform.iOS); + + @override + Future> listArchives() async { + await tryResolve('ios-release/Flutter.dSYM.zip'); + return _resolvedFiles; + } +} + +class MacOSSymbolResolver extends FlutterSymbolResolver { + MacOSSymbolResolver(super.bucket, super.prefix); + + @override + final platform = FakePlatform(operatingSystem: Platform.macOS); + + @override + Future> listArchives() async { + // darwin-x64-release directory contains a fat (arm64+x86_64) binary. + await tryResolve('darwin-x64-release/FlutterMacOS.dSYM.zip'); + return _resolvedFiles; + } +} + +class AndroidSymbolResolver extends FlutterSymbolResolver { + final String architecture; + + AndroidSymbolResolver(super.bucket, super.prefix, this.architecture); + + @override + final platform = FakePlatform(operatingSystem: Platform.android); + + @override + Future> listArchives() async { + await tryResolve('android-$architecture-release/symbols.zip'); + return _resolvedFiles; + } +} diff --git a/scripts/flutter_symbol_collector/lib/src/flutter_symbol_source.dart b/scripts/flutter_symbol_collector/lib/src/flutter_symbol_source.dart new file mode 100644 index 0000000000..aca3a1e187 --- /dev/null +++ b/scripts/flutter_symbol_collector/lib/src/flutter_symbol_source.dart @@ -0,0 +1,156 @@ +import 'dart:typed_data'; + +import 'package:archive/archive.dart'; +import 'package:archive/archive_io.dart'; +import 'package:file/file.dart'; +import 'package:github/github.dart' as github; +import 'package:gcloud/storage.dart'; +import 'package:http/http.dart'; +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as path; + +import 'flutter_version.dart'; +import 'flutter_symbol_resolver.dart'; +import 'symbol_archive.dart'; + +class FlutterSymbolSource { + late final Logger _log; + final github.GitHub _github; + late final _flutterRepo = github.RepositorySlug('flutter', 'flutter'); + late final _symbolsBucket = + Storage(Client(), '').bucket('flutter_infra_release'); + + FlutterSymbolSource( + {Logger? logger, + github.Authentication githubAuth = + const github.Authentication.anonymous()}) + : _log = logger ?? Logger.root, + _github = github.GitHub(auth: githubAuth); + + Stream listFlutterVersions() => _github.repositories + .listTags(_flutterRepo, perPage: 30) + .map((t) => FlutterVersion(t.name)); + + /// Returns false as the first record value in case there was any error fetching the symbol archives. + Future> listSymbolArchives(FlutterVersion version) async { + // example: https://console.cloud.google.com/storage/browser/flutter_infra_release/flutter/9064459a8b0dcd32877107f6002cc429a71659d1 + final prefix = 'flutter/${await version.engineVersion}/'; + + late final List resolvers; + if (version.tagName.startsWith('3.')) { + resolvers = [ + IosSymbolResolver(_symbolsBucket, prefix), + MacOSSymbolResolver(_symbolsBucket, prefix), + AndroidSymbolResolver(_symbolsBucket, prefix, 'arm'), + AndroidSymbolResolver(_symbolsBucket, prefix, 'arm64') + ]; + } else { + _log.warning('No symbol resolvers registered for ${version.tagName}'); + return []; + } + + assert(resolvers.isNotEmpty); + final archives = List.empty(growable: true); + for (var resolver in resolvers) { + final files = await resolver.listArchives(); + if (files.isEmpty) { + _log.warning( + 'Flutter ${version.tagName}: no debug symbols found by ${resolver.runtimeType}'); + } else { + _log.fine( + 'Flutter ${version.tagName}: ${resolver.runtimeType} found ${files.length} debug symbols: ${files.map((v) => path.basename(v.path))}'); + archives.addAll(files); + } + } + + return archives; + } + + /// Streams the remote file contents. + Stream> download(String filePath) { + _log.fine('Downloading $filePath'); + return _symbolsBucket.read(filePath); + } + + /// Downloads the remote [filePath] to the given [target] directory. + /// If it's an archive, extracts the content instead. + /// returns `true` if the file was downloaded and extracted successfully. + Future downloadAndExtractTo(Directory target, String filePath) async { + if (path.extension(filePath) == '.zip') { + target = await target + .childDirectory(path.withoutExtension(filePath)) + .create(recursive: true); + try { + final buffer = BytesBuilder(); + await download(filePath).forEach(buffer.add); + final archive = ZipDecoder().decodeBytes(buffer.toBytes()); + buffer.clear(); + _log.fine('Extracting $filePath to $target'); + await _extractZip(target, archive); + } catch (e, trace) { + _log.warning('Failed to download $filePath to $target', e, trace); + // Remove the directory so that we don't leave a partial extraction. + await target.delete(recursive: true); + return false; + } + } else { + _log.fine('Downloading $filePath to $target'); + final file = await target + .childFile(filePath) + .create(recursive: true, exclusive: true); + final sink = file.openWrite(); + try { + await sink.addStream(download(filePath)); + await sink.flush(); + await sink.close(); + } catch (e, trace) { + _log.warning('Failed to download $filePath to $target', e, trace); + await sink.close(); + await file.delete(); + return false; + } + } + return true; + } + + Future _extractZip(Directory target, Archive archive) async { + for (var entry in archive.files) { + // Make sure we don't have any zip-slip issues. + final entryPath = _pathNormalize(entry.name); + if (!_pathNormalize(target.childFile(entryPath).path) + .startsWith(target.path)) { + throw Exception( + 'Invalid ZIP entry path (looks like a zip-slip issue): ${entry.name}'); + } + + if (!entry.isFile) { + // If it's a directory - create it. + await target.childDirectory(entryPath).create(recursive: true); + } else { + // Note: package:archive doesn't support extracting directly to an + // IOSink. See https://github.com/brendan-duncan/archive/issues/12 + final stream = OutputStream(); + entry.writeContent(stream, freeMemory: true); + stream.flush(); + + // If it's an inner ZIP archive - extract it recursively. + if (path.extension(entryPath) == '.zip') { + final innerArchive = ZipDecoder().decodeBytes(stream.getBytes()); + stream.clear(); + final innerTarget = + target.childDirectory(path.withoutExtension(entryPath)); + _log.fine('Extracting inner archive $entryPath to $innerTarget'); + await _extractZip(innerTarget, innerArchive); + } else { + final file = + await target.childFile(entryPath).create(exclusive: true); + _log.finer('Writing $file: ${stream.length} bytes'); + await file.writeAsBytes(stream.getBytes(), flush: true); + } + } + } + } + + String _pathNormalize(String p) => + path.normalize(p).replaceAll(path.separator, '/'); +} diff --git a/scripts/flutter_symbol_collector/lib/src/flutter_version.dart b/scripts/flutter_symbol_collector/lib/src/flutter_version.dart new file mode 100644 index 0000000000..6461a4c1fe --- /dev/null +++ b/scripts/flutter_symbol_collector/lib/src/flutter_version.dart @@ -0,0 +1,16 @@ +import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; + +@immutable +class FlutterVersion { + final String tagName; + + late final engineVersion = http + .get(Uri.https('raw.githubusercontent.com', + 'flutter/flutter/$tagName/bin/internal/engine.version')) + .then((value) => value.body.trim()); + + FlutterVersion(this.tagName); + + bool get isPreRelease => tagName.endsWith('.pre'); +} diff --git a/scripts/flutter_symbol_collector/lib/src/status_cache.dart b/scripts/flutter_symbol_collector/lib/src/status_cache.dart new file mode 100644 index 0000000000..6c456b73c8 --- /dev/null +++ b/scripts/flutter_symbol_collector/lib/src/status_cache.dart @@ -0,0 +1,61 @@ +import 'package:file/file.dart'; +import 'package:logging/logging.dart'; + +import 'symbol_archive.dart'; + +enum SymbolArchiveStatus { + /// The archive has been successfully processed. + success, + + /// The archive has been processed but there was an error. + error, + + /// The archive hasn't been processed yet + pending, +} + +/// Stores and retrieves information about symbol processing status. +abstract class SymbolArchiveStatusCache { + Future setStatus(SymbolArchive archive, SymbolArchiveStatus status); + Future getStatus(SymbolArchive archive); +} + +/// Stores information about symbol processing status in a local directory. +class DirectoryStatusCache implements SymbolArchiveStatusCache { + final Directory _dir; + + DirectoryStatusCache(this._dir) { + _dir.createSync(recursive: true); + } + + File _statusFile(SymbolArchive archive) => + _dir.childFile('${archive.path.toLowerCase()}.status'); + + @override + Future getStatus(SymbolArchive archive) async { + final file = _statusFile(archive); + if (!await file.exists()) { + return SymbolArchiveStatus.pending; + } + return file.readAsString().then((value) { + switch (value) { + case 'success': + return SymbolArchiveStatus.success; + case 'error': + return SymbolArchiveStatus.error; + default: + Logger.root.warning('Unknown status \'$value\' in $file'); + return SymbolArchiveStatus.error; + } + }); + } + + @override + Future setStatus( + SymbolArchive archive, SymbolArchiveStatus status) async { + final file = _statusFile(archive); + Logger.root.info('Setting ${file.path} status to ${status.name}'); + await file.create(recursive: true); + await file.writeAsString(status.name); + } +} diff --git a/scripts/flutter_symbol_collector/lib/src/symbol_archive.dart b/scripts/flutter_symbol_collector/lib/src/symbol_archive.dart new file mode 100644 index 0000000000..f4681e44be --- /dev/null +++ b/scripts/flutter_symbol_collector/lib/src/symbol_archive.dart @@ -0,0 +1,10 @@ +import 'package:platform/platform.dart'; +import 'package:meta/meta.dart'; + +@immutable +class SymbolArchive { + final String path; + final Platform platform; + + SymbolArchive(this.path, this.platform); +} diff --git a/scripts/flutter_symbol_collector/lib/src/symbol_collector_cli.dart b/scripts/flutter_symbol_collector/lib/src/symbol_collector_cli.dart new file mode 100644 index 0000000000..0f4c97d6a6 --- /dev/null +++ b/scripts/flutter_symbol_collector/lib/src/symbol_collector_cli.dart @@ -0,0 +1,147 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:archive/archive_io.dart'; +import 'package:file/file.dart'; +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; +import 'package:meta/meta.dart'; +import 'package:platform/platform.dart'; +import 'package:posix/posix.dart' as posix; +import 'package:path/path.dart' as path; + +import 'flutter_version.dart'; + +class SymbolCollectorCli { + late final Logger _log = Logger.root; + late bool _isExecutable; + + // https://github.com/getsentry/symbol-collector/releases + @internal + static const version = '1.12.1'; + + @internal + late final String cli; + + @internal + static Platform platform = LocalPlatform(); + + SymbolCollectorCli._(); + + // Downloads the CLI to the given temporary directory and prepares it for use. + static Future setup(Directory tempDir) async { + late final String platformIdentifier; + final executableName = 'symbol-collector'; + + if (platform.isLinux) { + platformIdentifier = 'linux-x64'; + } else if (platform.isMacOS) { + platformIdentifier = 'osx-x64'; + } else { + throw UnsupportedError( + 'Cannot run symbol-collector CLI on this platform - there\'s no binary available at this time.'); + } + + final self = SymbolCollectorCli._(); + + self._log.fine( + 'Downloading symbol-collector CLI v$version for $platformIdentifier'); + final zipData = await http.readBytes(Uri.parse( + 'https://github.com/getsentry/symbol-collector/releases/download/$version/symbolcollector-console-$platformIdentifier.zip')); + self._log.fine( + 'Download successful, received ${zipData.length} bytes; extracting the archive'); + + final archive = ZipDecoder().decodeBytes(zipData); + final stream = OutputStream(); + archive.single.writeContent(stream, freeMemory: true); + stream.flush(); + + await tempDir.create(); + final executableFile = await tempDir.childFile(executableName).create(); + self.cli = executableFile.path; + + await executableFile.writeAsBytes(stream.getBytes(), flush: true); + self._log.fine( + 'Symbol-collector CLI extracted to ${executableFile.path}: ${await executableFile.length()} bytes'); + self._isExecutable = platform.isWindows; + return self; + } + + void _ensureIsExecutable() { + if (!_isExecutable) { + if (LocalPlatform().operatingSystem == platform.operatingSystem) { + if (platform.isLinux || platform.isMacOS) { + _log.fine('Making Symbol-collector CLI executable (chmod +x)'); + + posix.chmod(cli, '0700'); + } + _isExecutable = true; + } else { + _log.warning( + 'Symbol-collector CLI has been run with a platform that is not the current OS platform.' + 'This should only be done in tests because we can\'t execute the downloaded program'); + } + } + } + + Future getVersion() => _execute(['--version', '-h']); + + Future upload( + Directory dir, Platform symbolsPlatform, FlutterVersion flutterVersion, + {bool dryRun = false}) async { + final type = symbolsPlatform.operatingSystem; + try { + await _execute([ + '--upload', + 'directory', + '--path', + dir.path, + '--batch-type', + type, + '--bundle-id', + 'flutter-${flutterVersion.tagName}-$type', + '--server-endpoint', + 'https://symbol-collector.services.sentry.io/', + ]); + } catch (e) { + _log.warning('Failed to upload symbols from ${dir.path}', e); + return false; + } + return true; + } + + Future _execute(List arguments) async { + _ensureIsExecutable(); + + _log.fine('Executing ${path.basename(cli)} ${arguments.join(' ')}'); + final process = await Process.start(cli, arguments); + + final output = StringBuffer(); + handleOutput(Level level, String message) { + message.trimRight().split('\n').forEach((s) => _log.log(level, ' $s')); + output.write(message); + } + + final pipes = [ + process.stdout + .transform(utf8.decoder) + .forEach((s) => handleOutput(Level.FINER, s)), + process.stderr + .transform(utf8.decoder) + .forEach((s) => handleOutput(Level.SEVERE, s)) + ]; + + final exitCode = await process.exitCode; + await Future.wait(pipes); + final strOutput = output.toString().trimRight(); + if (exitCode != 0) { + throw Exception('Symbol-collector CLI failed with exit code $exitCode.'); + } else if (strOutput.contains('Exception:')) { + // see https://github.com/getsentry/symbol-collector/issues/167 + throw Exception('Symbol-collector CLI failed with an exception.'); + } + + return strOutput; + } +} diff --git a/scripts/flutter_symbol_collector/pubspec.yaml b/scripts/flutter_symbol_collector/pubspec.yaml new file mode 100644 index 0000000000..0464e16e0d --- /dev/null +++ b/scripts/flutter_symbol_collector/pubspec.yaml @@ -0,0 +1,24 @@ +name: flutter_symbol_collector +description: Internal tool to collect Flutter debug symbols and upload them to Sentry +publish_to: none + +environment: + sdk: ^3.0.0 + +dependencies: + archive: ^3.4.6 + args: ^2.4.2 + file: ^7.0.0 + gcloud: ^0.8.11 + github: ^9.19.0 + http: ^1.1.0 + logging: ^1.2.0 + meta: ^1.11.0 + path: ^1.8.3 + platform: ^3.1.3 + posix: ^5.0.0 + + +dev_dependencies: + lints: ^2.0.0 + test: ^1.21.0 diff --git a/scripts/flutter_symbol_collector/test/common.dart b/scripts/flutter_symbol_collector/test/common.dart new file mode 100644 index 0000000000..22d1877435 --- /dev/null +++ b/scripts/flutter_symbol_collector/test/common.dart @@ -0,0 +1,9 @@ +import 'package:logging/logging.dart'; + +void setupLogging() { + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + print('${record.level.name}: ${record.time}: ${record.message}' + '${record.error == null ? '' : ': ${record.error}'}'); + }); +} diff --git a/scripts/flutter_symbol_collector/test/flutter_symbol_source_test.dart b/scripts/flutter_symbol_collector/test/flutter_symbol_source_test.dart new file mode 100644 index 0000000000..9153ac3128 --- /dev/null +++ b/scripts/flutter_symbol_collector/test/flutter_symbol_source_test.dart @@ -0,0 +1,81 @@ +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_symbol_collector/flutter_symbol_collector.dart'; +import 'package:test/test.dart'; + +import 'common.dart'; + +void main() { + setupLogging(); + late FileSystem fs; + late FlutterSymbolSource sut; + + setUp(() { + fs = MemoryFileSystem.test(); + sut = FlutterSymbolSource(); + }); + + test('listFlutterVersions() returns a stable list', () async { + final versions = await sut.listFlutterVersions().take(3).toList(); + expect(versions.map((v) => v.tagName), + equals(['v1.16.3', 'v1.16.2', 'v1.16.1'])); + }); + + test('listFlutterVersions() fetches items across multiple API page requests', + () async { + // the page size defaults to 30 at the moment, see listFlutterVersions() + final versions = await sut.listFlutterVersions().take(105).toList(); + expect(versions.length, equals(105)); + }); + + test('Engine versions match expected values', () async { + final versions = await sut.listFlutterVersions().take(3).toList(); + final engines = List.empty(growable: true); + for (var v in versions) { + engines.add("${v.tagName} => ${await v.engineVersion}"); + } + expect( + engines, + equals([ + 'v1.16.3 => b2bdeb3f0f1683f3e0562f491b5e316240dfbc2c', + 'v1.16.2 => 2d42c74a348d98d2fd372a91953c104e58f185cd', + 'v1.16.1 => 216c420a2c06e5266a60a768b3fd0b660551cc9c' + ])); + }); + + test('listSymbolArchives() supports expected platforms', () async { + final archives = await sut.listSymbolArchives(FlutterVersion('3.13.4')); + const prefix = 'flutter/9064459a8b0dcd32877107f6002cc429a71659d1'; + expect( + archives.map((v) => '${v.platform.operatingSystem} - ${v.path}'), + equals([ + 'ios - $prefix/ios-release/Flutter.dSYM.zip', + 'macos - $prefix/darwin-x64-release/FlutterMacOS.dSYM.zip', + 'android - flutter/9064459a8b0dcd32877107f6002cc429a71659d1/android-arm-release/symbols.zip', + 'android - flutter/9064459a8b0dcd32877107f6002cc429a71659d1/android-arm64-release/symbols.zip' + ])); + }); + + test('download() downloads the file', () async { + // No need to download a large archive, just some small file to test this. + final content = await sut + .download('test.txt') + .map(String.fromCharCodes) + .reduce((a, b) => '$a$b'); + expect(content, equals('test\n')); + }); + + test('downloadAndExtractTo() downloads a plain file', () async { + await sut.downloadAndExtractTo(fs.currentDirectory, 'test.txt'); + expect(fs.isFileSync('test.txt'), isTrue); + expect(fs.file('test.txt').readAsStringSync(), equals('test\n')); + }); + + test('downloadAndExtractTo() extracts a zip file', () async { + const path = 'flutter/0005149dca9b248663adcde4bdd7c6c915a76584'; + await sut.downloadAndExtractTo(fs.currentDirectory, '$path/sky_engine.zip'); + expect(fs.isDirectorySync('$path/sky_engine/sky_engine'), isTrue); + expect(fs.file('$path/sky_engine/sky_engine/README.md').readAsStringSync(), + startsWith('Flutter Engine')); + }); +} diff --git a/scripts/flutter_symbol_collector/test/flutter_version_test.dart b/scripts/flutter_symbol_collector/test/flutter_version_test.dart new file mode 100644 index 0000000000..5786292dc0 --- /dev/null +++ b/scripts/flutter_symbol_collector/test/flutter_version_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter_symbol_collector/flutter_symbol_collector.dart'; +import 'package:test/test.dart'; + +void main() { + test('$FlutterVersion.isPrerelease()', () async { + expect(FlutterVersion('v1.16.3').isPreRelease, false); + expect(FlutterVersion('v1.16.3.pre').isPreRelease, true); + expect(FlutterVersion('3.16.0-9.0').isPreRelease, false); + expect(FlutterVersion('3.16.0-9.0.pre').isPreRelease, true); + }); +} diff --git a/scripts/flutter_symbol_collector/test/status_cache_test.dart b/scripts/flutter_symbol_collector/test/status_cache_test.dart new file mode 100644 index 0000000000..f8b929db79 --- /dev/null +++ b/scripts/flutter_symbol_collector/test/status_cache_test.dart @@ -0,0 +1,43 @@ +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_symbol_collector/flutter_symbol_collector.dart'; +import 'package:platform/platform.dart'; +import 'package:test/test.dart'; + +import 'common.dart'; + +void main() { + setupLogging(); + + group('DirectoryStatusCache', () { + late FileSystem fs; + late SymbolArchiveStatusCache sut; + final archive = SymbolArchive('path/to/archive.zip', LocalPlatform()); + + setUp(() { + fs = MemoryFileSystem.test(); + sut = DirectoryStatusCache(fs.currentDirectory); + }); + + test('retrieve unprocessed file', () async { + expect(await sut.getStatus(archive), SymbolArchiveStatus.pending); + }); + + test('store and retrieve error', () async { + await sut.setStatus(archive, SymbolArchiveStatus.error); + expect(await sut.getStatus(archive), SymbolArchiveStatus.error); + }); + + test('store and retrieve success', () async { + await sut.setStatus(archive, SymbolArchiveStatus.success); + expect(await sut.getStatus(archive), SymbolArchiveStatus.success); + }); + + test('store, overwrite and retrieve', () async { + await sut.setStatus(archive, SymbolArchiveStatus.error); + expect(await sut.getStatus(archive), SymbolArchiveStatus.error); + await sut.setStatus(archive, SymbolArchiveStatus.success); + expect(await sut.getStatus(archive), SymbolArchiveStatus.success); + }); + }); +} diff --git a/scripts/flutter_symbol_collector/test/symbol_collector_cli_test.dart b/scripts/flutter_symbol_collector/test/symbol_collector_cli_test.dart new file mode 100644 index 0000000000..dbd9a50e12 --- /dev/null +++ b/scripts/flutter_symbol_collector/test/symbol_collector_cli_test.dart @@ -0,0 +1,72 @@ +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_symbol_collector/src/flutter_version.dart'; +import 'package:flutter_symbol_collector/src/symbol_collector_cli.dart'; +import 'package:platform/platform.dart'; +import 'package:test/test.dart'; + +import 'common.dart'; + +void main() { + setupLogging(); + + group('setup() downloads CLI on', () { + late FileSystem fs; + + setUp(() { + fs = MemoryFileSystem.test(); + }); + for (final platform in [Platform.macOS, Platform.linux]) { + test(platform, () async { + const path = 'temp/symbol-collector'; + + // make sure the file is overwritten if there's an older version + await fs + .file(path) + .create(recursive: true) + .then((file) => file.writeAsString('foo')); + expect(fs.file(path).lengthSync(), equals(3)); + + final originalPlatform = SymbolCollectorCli.platform; + try { + SymbolCollectorCli.platform = FakePlatform(operatingSystem: platform); + final sut = await SymbolCollectorCli.setup(fs.directory('temp')); + expect(sut.cli, equals(path)); + expect(fs.file(path).existsSync(), isTrue); + expect(fs.file(path).lengthSync(), greaterThan(1000000)); + } finally { + SymbolCollectorCli.platform = originalPlatform; + } + }); + } + }); + + group('execute', () { + final tmpDir = LocalFileSystem() + .systemTempDirectory + .createTempSync('symbol_collector_test'); + late final SymbolCollectorCli sut; + + setUpAll(() async => sut = await SymbolCollectorCli.setup(tmpDir)); + tearDownAll(() => tmpDir.delete(recursive: true)); + + test('getVersion()', () async { + final output = await sut.getVersion(); + expect(output, startsWith('${SymbolCollectorCli.version}+')); + expect(output.split("\n").length, equals(1)); + }); + + test('upload()', () async { + final uploadDir = LocalFileSystem() + .systemTempDirectory + .createTempSync('symbol_collector_upload_test'); + try { + await sut.upload( + uploadDir, LocalPlatform(), FlutterVersion('v0.0.0-test')); + } finally { + uploadDir.deleteSync(); + } + }); + }, skip: LocalPlatform().isWindows); +} diff --git a/scripts/update-symbol-collector.sh b/scripts/update-symbol-collector.sh new file mode 100755 index 0000000000..0df03b9434 --- /dev/null +++ b/scripts/update-symbol-collector.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd $(dirname "$0")/flutter_symbol_collector +file='lib/src/symbol_collector_cli.dart' +content=$(cat $file) +regex="(static const version = )'([0-9\.]+)'" +if ! [[ $content =~ $regex ]]; then + echo "Failed to find the plugin version in $file" + exit 1 +fi + +case $1 in +get-version) + echo ${BASH_REMATCH[2]} + ;; +get-repo) + echo "https://github.com/getsentry/symbol-collector.git" + ;; +set-version) + newValue="${BASH_REMATCH[1]}'$2'" + echo "${content/${BASH_REMATCH[0]}/$newValue}" >$file + ;; +*) + echo "Unknown argument $1" + exit 1 + ;; +esac