From feb4541df68bc5884d4704d2629e4b315982eb51 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 13 Aug 2026 16:59:48 +0200 Subject: [PATCH 1/7] capture all the envelopes --- .github/workflows/ci.yml | 61 ++++- .github/workflows/test-run-android.yml | 22 ++ .github/workflows/test-run-desktop.yml | 19 ++ .github/workflows/test-run-ios.yml | 20 ++ .github/workflows/test-run-webgl.yml | 20 ++ scripts/replay-envelopes.py | 237 ++++++++++++++++++ test/IntegrationTest/Integration.Tests.ps1 | 53 +++- .../Editor/AllowInsecureHttp.cs | 62 +++++ .../envelope-capture-server.py | 222 ++++++++++++++++ .../start-capture-server.ps1 | 42 ++++ 10 files changed, 737 insertions(+), 21 deletions(-) create mode 100644 scripts/replay-envelopes.py create mode 100644 test/Scripts.Integration.Test/envelope-capture-server.py create mode 100644 test/Scripts.Integration.Test/start-capture-server.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94ca1885c..bcc52bb5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -238,7 +238,7 @@ jobs: run: ./test/Scripts.Integration.Test/configure-sentry.ps1 -UnityPath "$env:UNITY_PATH" -Platform "$env:BUILD_PLATFORM" env: BUILD_PLATFORM: ${{ matrix.build_platform }} - SENTRY_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md - name: Build Project run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform "$env:BUILD_PLATFORM" -UnityVersion "$env:UNITY_VERSION" @@ -294,7 +294,7 @@ jobs: secrets: UNITY_LICENSE_SERVER_CONFIG: ${{ secrets.UNITY_LICENSE_SERVER_CONFIG }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_TEST_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: ${{ fromJSON(needs.create-unity-matrix.outputs.unity-matrix) }} @@ -308,7 +308,7 @@ jobs: needs: [test-build-android, create-unity-matrix] secrets: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_TEST_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md uses: ./.github/workflows/test-run-android.yml with: unity-version: ${{ matrix.unity-version }} @@ -329,7 +329,7 @@ jobs: secrets: UNITY_LICENSE_SERVER_CONFIG: ${{ secrets.UNITY_LICENSE_SERVER_CONFIG }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_TEST_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: ${{ fromJSON(needs.create-unity-matrix.outputs.unity-matrix) }} @@ -362,7 +362,7 @@ jobs: init-type: ${{ matrix.init-type }} secrets: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_TEST_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -386,7 +386,7 @@ jobs: needs: [test-build-webgl, create-unity-matrix] secrets: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_TEST_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -402,7 +402,7 @@ jobs: secrets: UNITY_LICENSE_SERVER_CONFIG: ${{ secrets.UNITY_LICENSE_SERVER_CONFIG }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_TEST_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -418,7 +418,7 @@ jobs: secrets: UNITY_LICENSE_SERVER_CONFIG: ${{ secrets.UNITY_LICENSE_SERVER_CONFIG }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_TEST_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -434,7 +434,7 @@ jobs: secrets: UNITY_LICENSE_SERVER_CONFIG: ${{ secrets.UNITY_LICENSE_SERVER_CONFIG }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_TEST_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -449,7 +449,7 @@ jobs: needs: [test-build-linux, create-unity-matrix] secrets: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_TEST_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -467,7 +467,7 @@ jobs: needs: [test-build-windows, create-unity-matrix] secrets: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_TEST_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -485,7 +485,7 @@ jobs: needs: [test-build-macos, create-unity-matrix] secrets: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: ${{ secrets.SENTRY_TEST_DSN }} + SENTRY_TEST_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -497,6 +497,43 @@ jobs: platform: macos backend: ${{ matrix.backend }} + # Merges the per-job envelope artifacts into a single `envelopes-all` download. + # See docs/envelope-capture.md. + collect-envelopes: + name: Collect captured envelopes + runs-on: ubuntu-latest + if: ${{ always() }} + needs: [test-run-android, test-run-ios, test-run-webgl, test-run-linux, test-run-windows, test-run-macos] + steps: + - name: Download all envelope artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: envelopes-* + merge-multiple: true + path: envelopes + + - name: Summarize corpus + run: | + "## Captured envelopes`n" >> $env:GITHUB_STEP_SUMMARY + "| Platform | Payloads | Test actions |" >> $env:GITHUB_STEP_SUMMARY + "| --- | --- | --- |" >> $env:GITHUB_STEP_SUMMARY + $total = 0 + Get-ChildItem -Path envelopes -Recurse -Filter index.jsonl | Sort-Object { $_.Directory.Name } | ForEach-Object { + $entries = Get-Content $_.FullName | ForEach-Object { $_ | ConvertFrom-Json } + $total += $entries.Count + $actions = ($entries | ForEach-Object { $_.label } | Sort-Object -Unique) -join ", " + "| $($_.Directory.Name) | $($entries.Count) | $actions |" >> $env:GITHUB_STEP_SUMMARY + } + "`nTotal: $total payloads" >> $env:GITHUB_STEP_SUMMARY + + - name: Upload combined corpus + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: envelopes-all + path: envelopes/ + if-no-files-found: error + retention-days: 14 + build-size-summary: name: Build Size runs-on: ubuntu-latest diff --git a/.github/workflows/test-run-android.yml b/.github/workflows/test-run-android.yml index 67b6471f6..9a5caf4de 100644 --- a/.github/workflows/test-run-android.yml +++ b/.github/workflows/test-run-android.yml @@ -84,6 +84,13 @@ jobs: mkdir -p $HOME/.android/avd touch $HOME/.android/repositories.cfg + # Envelope capture: only active when ci.yml was dispatched with `capture-envelopes`, which + # points the DSN baked into the test build at 127.0.0.1:8000. `adb reverse` below tunnels that + # port from the device to the runner. See docs/envelope-capture.md. + - name: Start envelope capture server + if: contains(env.SENTRY_DSN, '127.0.0.1') + run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "android-${{ inputs.api-level }}-${{ inputs.unity-version }}-${{ inputs.init-type }}" + - name: Run Android Integration Tests (emulator) if: ${{ steps.device.outputs.device == 'emulator' }} uses: reactivecircus/android-emulator-runner@0a638108440efd5c7f980e6ba145dbcdd8f32009 # v2.37.0 @@ -111,6 +118,7 @@ jobs: adb wait-for-device adb shell input keyevent 82 adb devices -l + adb reverse tcp:8000 tcp:8000 pwsh -Command '$env:SENTRY_TEST_PLATFORM = "Android"; $env:SENTRY_TEST_APP = "samples/IntegrationTest/Build/test.apk"; Invoke-Pester -Path test/IntegrationTest/Integration.Tests.ps1 -CI' # --- arm64 path: Redroid container on the arm64 runner (no hypervisor needed) --- @@ -168,10 +176,24 @@ jobs: Start-Sleep -Seconds 5 } adb devices -l + adb reverse tcp:8000 tcp:8000 $env:SENTRY_TEST_PLATFORM = "Android" $env:SENTRY_TEST_APP = "samples/IntegrationTest/Build/test.apk" Invoke-Pester -Path test/IntegrationTest/Integration.Tests.ps1 -CI + - name: Stop envelope capture server + if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} + run: Invoke-WebRequest -Uri "http://127.0.0.1:8000/STOP" -TimeoutSec 5 -UseBasicParsing -ErrorAction SilentlyContinue | Out-Null + + - name: Upload captured envelopes + if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: envelopes-android-${{ inputs.api-level }}-${{ inputs.unity-version }}-${{ inputs.init-type }} + path: test/IntegrationTest/envelopes/ + if-no-files-found: warn + retention-days: 14 + - name: Upload test results on failure if: ${{ failure() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/test-run-desktop.yml b/.github/workflows/test-run-desktop.yml index 979ee9a45..d00202a28 100644 --- a/.github/workflows/test-run-desktop.yml +++ b/.github/workflows/test-run-desktop.yml @@ -58,6 +58,12 @@ jobs: run: chmod +x samples/IntegrationTest/Build/test.app/Contents/MacOS/IntegrationTest shell: bash + # Envelope capture: only active when ci.yml was dispatched with `capture-envelopes`, which + # points the DSN baked into the test build at 127.0.0.1:8000. See docs/envelope-capture.md. + - name: Start envelope capture server + if: contains(env.SENTRY_DSN, '127.0.0.1') + run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "${{ inputs.platform }}${{ inputs.backend && format('-{0}', inputs.backend) || '' }}-${{ inputs.unity-version }}" + - name: Run Integration Tests (Linux) if: inputs.platform == 'linux' timeout-minutes: 20 @@ -86,6 +92,19 @@ jobs: $env:SENTRY_TEST_APP = "samples/IntegrationTest/Build/test.exe" Invoke-Pester -Path test/IntegrationTest/Integration.Tests.ps1 -CI + - name: Stop envelope capture server + if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} + run: Invoke-WebRequest -Uri "http://127.0.0.1:8000/STOP" -TimeoutSec 5 -UseBasicParsing -ErrorAction SilentlyContinue | Out-Null + + - name: Upload captured envelopes + if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: envelopes-${{ inputs.platform }}${{ inputs.backend && format('-{0}', inputs.backend) || '' }}-${{ inputs.unity-version }} + path: test/IntegrationTest/envelopes/ + if-no-files-found: warn + retention-days: 14 + - name: Upload test results on failure if: ${{ failure() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/test-run-ios.yml b/.github/workflows/test-run-ios.yml index 7d1e0bd89..fb44bc5d3 100644 --- a/.github/workflows/test-run-ios.yml +++ b/.github/workflows/test-run-ios.yml @@ -65,6 +65,13 @@ jobs: with: xcode-version: '15.0' # to run iOS 17.0 we need Xcode 15.0 + # Envelope capture: only active when ci.yml was dispatched with `capture-envelopes`, which + # points the DSN baked into the test build at 127.0.0.1:8000. The simulator shares the host + # loopback, so no port forwarding is needed. See docs/envelope-capture.md. + - name: Start envelope capture server + if: contains(env.SENTRY_DSN, '127.0.0.1') + run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "ios-${{ env.IOS_VERSION }}-${{ env.UNITY_VERSION }}-${{ env.INIT_TYPE }}" + - name: Run iOS Integration Tests id: integration-test timeout-minutes: 20 @@ -76,6 +83,19 @@ jobs: Invoke-Pester -Path test/IntegrationTest/Integration.Tests.ps1 -CI echo "status=success" >> $env:GITHUB_OUTPUT + - name: Stop envelope capture server + if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} + run: Invoke-WebRequest -Uri "http://127.0.0.1:8000/STOP" -TimeoutSec 5 -UseBasicParsing -ErrorAction SilentlyContinue | Out-Null + + - name: Upload captured envelopes + if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: envelopes-ios-${{ env.IOS_VERSION }}-${{ env.UNITY_VERSION }}-${{ env.INIT_TYPE }} + path: test/IntegrationTest/envelopes/ + if-no-files-found: warn + retention-days: 14 + - name: Upload test results on failure if: ${{ failure() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/test-run-webgl.yml b/.github/workflows/test-run-webgl.yml index cff15cb81..51c6e2dbb 100644 --- a/.github/workflows/test-run-webgl.yml +++ b/.github/workflows/test-run-webgl.yml @@ -43,6 +43,13 @@ jobs: run: pip3 install --upgrade selenium shell: bash + # Envelope capture: only active when ci.yml was dispatched with `capture-envelopes`, which + # points the DSN baked into the test build at 127.0.0.1:8000. The capture server answers CORS + # preflights so the browser can post to it cross-origin. See docs/envelope-capture.md. + - name: Start envelope capture server + if: contains(env.SENTRY_DSN, '127.0.0.1') + run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "webgl-${{ inputs.unity-version }}" + - name: Run Integration Tests timeout-minutes: 20 run: | @@ -51,6 +58,19 @@ jobs: $env:SENTRY_TEST_UNITY_VERSION = "${{ inputs.unity-version }}" Invoke-Pester -Path test/IntegrationTest/Integration.Tests.ps1 -CI + - name: Stop envelope capture server + if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} + run: Invoke-WebRequest -Uri "http://127.0.0.1:8000/STOP" -TimeoutSec 5 -UseBasicParsing -ErrorAction SilentlyContinue | Out-Null + + - name: Upload captured envelopes + if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: envelopes-webgl-${{ inputs.unity-version }} + path: test/IntegrationTest/envelopes/ + if-no-files-found: warn + retention-days: 14 + - name: Upload test results on failure if: ${{ failure() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/scripts/replay-envelopes.py b/scripts/replay-envelopes.py new file mode 100644 index 000000000..9ab2141a3 --- /dev/null +++ b/scripts/replay-envelopes.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Replays a captured envelope corpus into a Sentry instance. + +Takes the output of test/Scripts.Integration.Test/envelope-capture-server.py (envelopes and +crashpad minidump uploads produced by the Unity integration tests on every platform) and posts +it to the DSN of your choice - typically a local Sentry. + +By default every replay gets fresh event ids and timestamps shifted to now, so the same corpus +can be replayed repeatedly without events deduplicating or falling outside the ingest window. + +Usage: + replay-envelopes.py --dsn http://@localhost:9000/1 + replay-envelopes.py --dsn ... --include '*crash*' --dry-run +""" + +import argparse +import fnmatch +import json +import sys +import urllib.error +import urllib.request +import uuid +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import parse_qsl, urlencode, urlparse + +TIMESTAMP_KEYS = {"timestamp", "start_timestamp", "started", "received", "time"} +JSON_ITEM_TYPES = {"event", "transaction", "session", "sessions", "check_in", "log", "feedback", + "user_report", "replay_event", "profile", "client_report"} + + +def parse_envelope(data): + """Splits envelope bytes into (header, [(item_header, payload)]).""" + newline = data.find(b"\n") + if newline == -1: + raise ValueError("no envelope header") + header = json.loads(data[:newline]) + items = [] + pos = newline + 1 + while pos < len(data): + if data[pos:pos + 1] == b"\n": + pos += 1 + continue + newline = data.find(b"\n", pos) + if newline == -1: + break + item_header = json.loads(data[pos:newline]) + pos = newline + 1 + if "length" in item_header: + end = pos + int(item_header["length"]) + else: + end = data.find(b"\n", pos) + if end == -1: + end = len(data) + items.append((item_header, data[pos:end])) + pos = end + return header, items + + +def serialize_envelope(header, items): + out = [json.dumps(header, separators=(",", ":")).encode(), b"\n"] + for item_header, payload in items: + item_header = dict(item_header, length=len(payload)) + out += [json.dumps(item_header, separators=(",", ":")).encode(), b"\n", payload, b"\n"] + return b"".join(out) + + +def to_epoch(value): + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() + except ValueError: + return None + return None + + +def from_epoch(epoch, template): + if isinstance(template, (int, float)): + return epoch + return datetime.fromtimestamp(epoch, timezone.utc).isoformat().replace("+00:00", "Z") + + +def collect_timestamps(node, found): + if isinstance(node, dict): + for key, value in node.items(): + if key in TIMESTAMP_KEYS: + epoch = to_epoch(value) + if epoch: + found.append(epoch) + collect_timestamps(value, found) + elif isinstance(node, list): + for value in node: + collect_timestamps(value, found) + + +def shift_timestamps(node, delta): + if isinstance(node, dict): + for key, value in node.items(): + if key in TIMESTAMP_KEYS: + epoch = to_epoch(value) + if epoch: + node[key] = from_epoch(epoch + delta, value) + continue + shift_timestamps(value, delta) + elif isinstance(node, list): + for value in node: + shift_timestamps(value, delta) + + +def rewrite(data, dsn, new_ids, fresh_timestamps): + header, items = parse_envelope(data) + header["dsn"] = dsn.url + header["sent_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + parsed = [] + for item_header, payload in items: + if item_header.get("type") in JSON_ITEM_TYPES: + try: + parsed.append((item_header, json.loads(payload), True)) + continue + except (ValueError, UnicodeDecodeError): + pass + parsed.append((item_header, payload, False)) + + if fresh_timestamps: + found = [] + for _, payload, is_json in parsed: + if is_json: + collect_timestamps(payload, found) + if found: + delta = datetime.now(timezone.utc).timestamp() - max(found) + for _, payload, is_json in parsed: + if is_json: + shift_timestamps(payload, delta) + + if new_ids: + event_id = uuid.uuid4().hex + if "event_id" in header: + header["event_id"] = event_id + for _, payload, is_json in parsed: + if is_json and isinstance(payload, dict) and "event_id" in payload: + payload["event_id"] = event_id + + rebuilt = [ + (item_header, json.dumps(payload, separators=(",", ":")).encode() if is_json else payload) + for item_header, payload, is_json in parsed + ] + return serialize_envelope(header, rebuilt) + + +class Dsn: + def __init__(self, url): + parsed = urlparse(url) + if not parsed.username or not parsed.hostname or len(parsed.path) < 2: + raise ValueError(f"not a valid DSN: {url}") + self.url = url + self.key = parsed.username + self.project = parsed.path.strip("/") + port = f":{parsed.port}" if parsed.port else "" + self.base = f"{parsed.scheme}://{parsed.hostname}{port}/api/{self.project}" + + def endpoint(self, name): + return f"{self.base}/{name}/" + + +def post(url, body, content_type, dsn, timeout): + auth = f"Sentry sentry_version=7, sentry_client=replay-envelopes/1.0, sentry_key={dsn.key}" + request = urllib.request.Request( + url, data=body, method="POST", + headers={"Content-Type": content_type, "X-Sentry-Auth": auth}) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return response.status, response.read(200).decode("utf-8", "replace") + except urllib.error.HTTPError as error: + return error.code, error.read(400).decode("utf-8", "replace") + except urllib.error.URLError as error: + return None, str(error.reason) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("corpus", help="directory with captured .envelope / .multipart.bin files") + parser.add_argument("--dsn", required=True, help="target DSN, e.g. http://key@localhost:9000/1") + parser.add_argument("--include", default="*", help="glob filter on the file name") + parser.add_argument("--keep-ids", action="store_true", help="replay original event ids") + parser.add_argument("--keep-timestamps", action="store_true", help="do not shift timestamps to now") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--timeout", type=float, default=30) + args = parser.parse_args() + + dsn = Dsn(args.dsn) + corpus = Path(args.corpus) + files = sorted(path for path in corpus.rglob("*") + if path.suffix in (".envelope", ".bin") and fnmatch.fnmatch(path.name, args.include)) + if not files: + print(f"no envelopes matching '{args.include}' under {corpus}", file=sys.stderr) + return 1 + + failures = 0 + for path in files: + data = path.read_bytes() + + if path.name.endswith(".multipart.bin"): + # crashpad minidump upload - replayed verbatim, only the ingest key is swapped + meta = json.loads(path.with_name(path.name[:-len(".multipart.bin")] + ".meta.json").read_text()) + content_type = meta["headers"].get("Content-Type", "multipart/form-data") + query = dict(parse_qsl(meta.get("query", ""))) + query["sentry_key"] = dsn.key + url = f"{dsn.endpoint('minidump')}?{urlencode(query)}" + else: + try: + data = rewrite(data, dsn, not args.keep_ids, not args.keep_timestamps) + except Exception as error: + print(f"SKIP {path.name}: cannot rewrite ({error})", file=sys.stderr) + failures += 1 + continue + content_type = "application/x-sentry-envelope" + url = dsn.endpoint("envelope") + + if args.dry_run: + print(f"DRY {path.name} -> {url} ({len(data)} bytes)") + continue + + status, body = post(url, data, content_type, dsn, args.timeout) + ok = status is not None and 200 <= status < 300 + failures += 0 if ok else 1 + print(f"{'OK ' if ok else 'FAIL'} {status if status else 'ERR'} {path.name} {body.strip()[:120]}") + + print(f"\n{len(files) - failures}/{len(files)} replayed to {dsn.base}") + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/IntegrationTest/Integration.Tests.ps1 b/test/IntegrationTest/Integration.Tests.ps1 index 9dec4f8eb..d5c267141 100644 --- a/test/IntegrationTest/Integration.Tests.ps1 +++ b/test/IntegrationTest/Integration.Tests.ps1 @@ -132,6 +132,26 @@ BeforeAll { return $runResult } + # Tags the envelopes that envelope-capture-server.py records next with the test action + # they belong to. No-op unless the DSN points at the local capture server. + function Set-CaptureLabel { + param ( + [Parameter(Mandatory=$true)] + [string]$Label + ) + + if (-not $script:CaptureMode) { + return + } + + try { + Invoke-WebRequest -Uri "http://127.0.0.1:8000/MARK?label=$Label" -TimeoutSec 5 -UseBasicParsing | Out-Null + } + catch { + Write-Host "Failed to mark capture label '$Label': $_" + } + } + # Run integration test action function Invoke-TestAction { param ( @@ -140,6 +160,7 @@ BeforeAll { ) Write-Host "Running $Action..." + Set-CaptureLabel -Label $Action if ($script:Platform -eq "WebGL") { return Invoke-WebGLTestAction -Action $Action @@ -160,6 +181,7 @@ BeforeAll { # Launch app again to ensure crash report is sent if ($Action -eq "crash-capture") { Write-Host "Running crash-send to ensure crash report is sent..." + Set-CaptureLabel -Label "crash-send" $sendArgs = Get-AppArguments -Action "crash-send" $sendResult = Invoke-DeviceApp -ExecutablePath $script:ExecutablePath -Arguments $sendArgs @@ -197,7 +219,16 @@ BeforeAll { if ([string]::IsNullOrEmpty($env:SENTRY_DSN)) { throw "SENTRY_DSN environment variable is not set." } - if ([string]::IsNullOrEmpty($env:SENTRY_AUTH_TOKEN)) { + + # Envelope capture mode: the DSN points at envelope-capture-server.py instead of Sentry, so the + # test actions still run (and their raw envelopes get recorded) but there is no backend to verify + # against. The event assertions below fail by design in this mode - the artifact is the corpus. + $script:CaptureMode = $env:SENTRY_DSN -match '://[^@]*@(127\.0\.0\.1|localhost|10\.0\.2\.2)' + if ($script:CaptureMode) { + Write-Host "Envelope capture mode: DSN points at the local capture server, skipping Sentry API verification." -ForegroundColor Yellow + } + + if (-not $script:CaptureMode -and [string]::IsNullOrEmpty($env:SENTRY_AUTH_TOKEN)) { throw "SENTRY_AUTH_TOKEN environment variable is not set." } if ([string]::IsNullOrEmpty($env:SENTRY_TEST_APP)) { @@ -277,14 +308,18 @@ BeforeAll { AuthToken = $env:SENTRY_AUTH_TOKEN } - Connect-SentryApi ` - -ApiToken $script:TestSetup.AuthToken ` - -DSN $script:TestSetup.Dsn + if (-not $script:CaptureMode) { + Connect-SentryApi ` + -ApiToken $script:TestSetup.AuthToken ` + -DSN $script:TestSetup.Dsn + } } AfterAll { - Disconnect-SentryApi + if (-not $script:CaptureMode) { + Disconnect-SentryApi + } if ($script:Platform -ne "WebGL") { Disconnect-Device } @@ -299,7 +334,7 @@ Describe "Unity $($env:SENTRY_TEST_PLATFORM) Integration Tests" { $script:runResult = Invoke-TestAction -Action "message-capture" $eventId = Get-EventIds -AppOutput $script:runResult.Output -ExpectedCount 1 - if ($eventId) { + if ($eventId -and -not $script:CaptureMode) { Write-Host "::group::Getting event content" $script:runEvent = Get-SentryTestEvent -EventId "$eventId" Write-Host "::endgroup::" @@ -325,7 +360,7 @@ Describe "Unity $($env:SENTRY_TEST_PLATFORM) Integration Tests" { $script:runResult = Invoke-TestAction -Action "exception-capture" $eventId = Get-EventIds -AppOutput $script:runResult.Output -ExpectedCount 1 - if ($eventId) { + if ($eventId -and -not $script:CaptureMode) { Write-Host "::group::Getting event content" $script:runEvent = Get-SentryTestEvent -EventId "$eventId" Write-Host "::endgroup::" @@ -387,7 +422,7 @@ if ($env:SENTRY_TEST_PLATFORM -ne "WebGL") { } $eventId = Get-EventIds -AppOutput $script:runResult.Output -ExpectedCount 1 - if ($eventId) { + if ($eventId -and -not $script:CaptureMode) { Write-Host "::group::Getting event content" $script:runEvent = Get-SentryTestEvent -TagName "test.crash_id" -TagValue "$eventId" -TimeoutSeconds 300 Write-Host "::endgroup::" @@ -443,7 +478,7 @@ if ($env:SENTRY_TEST_PLATFORM -in "Desktop", "Android" -and -not $isCocoaBackend # The native app-hang event is captured in-proc (same run, no relaunch). Its event ID # is generated natively, so look it up by the unique scope tag the app sets instead. $hangId = Get-EventIds -AppOutput $script:runResult.Output -ExpectedCount 1 - if ($hangId) { + if ($hangId -and -not $script:CaptureMode) { Write-Host "::group::Getting event content" $script:runEvent = Get-SentryTestEvent -TagName "test.app_hang_id" -TagValue "$hangId" -TimeoutSeconds 300 Write-Host "::endgroup::" diff --git a/test/Scripts.Integration.Test/Editor/AllowInsecureHttp.cs b/test/Scripts.Integration.Test/Editor/AllowInsecureHttp.cs index 4482aada7..d2e687617 100644 --- a/test/Scripts.Integration.Test/Editor/AllowInsecureHttp.cs +++ b/test/Scripts.Integration.Test/Editor/AllowInsecureHttp.cs @@ -1,6 +1,8 @@ using System; using System.IO; using System.Reflection; +using System.Text.RegularExpressions; +using System.Xml; using UnityEditor; using UnityEditor.Build; using UnityEditor.Build.Reporting; @@ -21,6 +23,14 @@ public void OnPreprocessBuild(BuildReport report) public void OnPostprocessBuild(BuildReport report) { var pathToBuiltProject = report.summary.outputPath; + if (report.summary.platform == BuildTarget.StandaloneOSX) + { + // ATS applies to macOS players too and blocks plain HTTP to an IP literal, which is what + // the envelope capture server is. The iOS module isn't available on macOS build agents, + // so patch the plist as plain XML instead of going through PlistDocument. + AllowArbitraryLoadsInMacPlist(Path.Combine(pathToBuiltProject, "Contents", "Info.plist")); + } + if (report.summary.platform == BuildTarget.iOS) { var plistPath = Path.Combine(pathToBuiltProject, "Info.plist"); @@ -51,4 +61,56 @@ public void OnPostprocessBuild(BuildReport report) File.WriteAllText(plistPath, contents); } } + + private static void AllowArbitraryLoadsInMacPlist(string plistPath) + { + if (!File.Exists(plistPath)) + { + Debug.LogError($"Failed to find the plist at {plistPath}."); + return; + } + + var document = new XmlDocument { XmlResolver = null }; + // Parse (not Ignore) keeps the DOCTYPE in the document; the null resolver keeps us from + // fetching the external DTD Apple references. + using (var reader = XmlReader.Create(plistPath, new XmlReaderSettings { DtdProcessing = DtdProcessing.Parse, XmlResolver = null })) + { + document.Load(reader); + } + + var root = document.SelectSingleNode("/plist/dict"); + if (root is null) + { + Debug.LogError("Failed to find the root in the plist."); + return; + } + + foreach (XmlNode child in root.ChildNodes) + { + if (child.Name == "key" && child.InnerText == "NSAppTransportSecurity") + { + Debug.Log("AllowInsecureHttp: plist already contains NSAppTransportSecurity, nothing to do."); + return; + } + } + + var key = document.CreateElement("key"); + key.InnerText = "NSAppTransportSecurity"; + var value = document.CreateElement("dict"); + var allowKey = document.CreateElement("key"); + allowKey.InnerText = "NSAllowsArbitraryLoads"; + value.AppendChild(allowKey); + value.AppendChild(document.CreateElement("true")); + + root.AppendChild(key); + root.AppendChild(value); + document.Save(plistPath); + + // XmlDocument serializes the DOCTYPE with an empty internal subset (`...PropertyList-1.0.dtd"[]>`) + // which Apple's plist parser rejects. Drop it again. + var patched = Regex.Replace(File.ReadAllText(plistPath), @"(\[]*)\[\]>", "$1>"); + File.WriteAllText(plistPath, patched); + + Debug.Log("AllowInsecureHttp: added NSAllowsArbitraryLoads to the macOS plist."); + } } diff --git a/test/Scripts.Integration.Test/envelope-capture-server.py b/test/Scripts.Integration.Test/envelope-capture-server.py new file mode 100644 index 000000000..4ce6834b1 --- /dev/null +++ b/test/Scripts.Integration.Test/envelope-capture-server.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Captures raw Sentry envelopes sent by the integration test app. + +Stands in for Sentry's ingest endpoint: accepts every request, writes the body to disk +and answers 200 so the SDK considers the payload delivered. Point the DSN of the test +build at this server (host 127.0.0.1) and the run produces a corpus of real envelopes - +including native crash envelopes and crashpad minidump uploads - that can be replayed +against a local Sentry via scripts/replay-envelopes.py. + +Usage: + envelope-capture-server.py --output DIR [--host 0.0.0.0] [--port 8000] [--platform NAME] + +Control endpoints: + GET /HEALTH 200 once the server is serving + GET /MARK?label=foo tags subsequently captured files with `foo` (the test action) + GET /STOP shuts the server down +""" + +import argparse +import gzip +import json +import re +import sys +import threading +import uuid +import zlib +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +state_lock = threading.Lock() +sequence = 0 +label = "startup" +output_dir = Path(".") +platform_name = "unknown" + + +def parse_envelope(data): + """Splits envelope bytes into (header, [(item_header, payload)]).""" + newline = data.find(b"\n") + if newline == -1: + raise ValueError("no envelope header") + header = json.loads(data[:newline]) + items = [] + pos = newline + 1 + while pos < len(data): + if data[pos:pos + 1] == b"\n": + pos += 1 + continue + newline = data.find(b"\n", pos) + if newline == -1: + break + item_header = json.loads(data[pos:newline]) + pos = newline + 1 + if "length" in item_header: + end = pos + int(item_header["length"]) + else: + end = data.find(b"\n", pos) + if end == -1: + end = len(data) + items.append((item_header, data[pos:end])) + pos = end + return header, items + + +def decode_body(body, encoding): + if not encoding: + return body + encoding = encoding.lower() + try: + if encoding == "gzip": + return gzip.decompress(body) + if encoding in ("deflate", "zlib"): + return zlib.decompress(body) + except Exception as error: + print(f"failed to decompress {encoding} body: {error}", file=sys.stderr) + return body + + +def safe(value): + return re.sub(r"[^A-Za-z0-9_.-]", "_", value)[:60] or "unknown" + + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, fmt, *args): + print(f"{self.address_string()} - {fmt % args}", file=sys.stderr) + + def cors(self): + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "*") + self.send_header("Access-Control-Max-Age", "86400") + + def respond(self, code, payload=b"", content_type="application/json"): + self.send_response(code) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(payload))) + self.cors() + self.end_headers() + if payload: + self.wfile.write(payload) + + def do_OPTIONS(self): + self.respond(200) + + def do_GET(self): + global label + url = urlparse(self.path) + if url.path == "/HEALTH": + self.respond(200, b'{"ok":true}') + elif url.path == "/MARK": + new_label = parse_qs(url.query).get("label", ["unlabeled"])[0] + with state_lock: + label = safe(new_label) + print(f"--- mark: {label} ---", file=sys.stderr) + self.respond(200, b'{"ok":true}') + elif url.path == "/STOP": + self.respond(200, b'{"ok":true}') + threading.Thread(target=self.server.shutdown).start() + else: + self.respond(200, b"{}") + + def read_body(self): + if self.headers.get("Transfer-Encoding", "").lower() == "chunked": + chunks = [] + while True: + size = int(self.rfile.readline().split(b";")[0], 16) + if size == 0: + self.rfile.readline() + break + chunks.append(self.rfile.read(size)) + self.rfile.readline() + return b"".join(chunks) + return self.rfile.read(int(self.headers.get("Content-Length", 0))) + + def do_POST(self): + global sequence + url = urlparse(self.path) + raw = self.read_body() + body = decode_body(raw, self.headers.get("Content-Encoding")) + + with state_lock: + sequence += 1 + seq, current_label = sequence, label + + meta = { + "sequence": seq, + "label": current_label, + "platform": platform_name, + "received": datetime.now(timezone.utc).isoformat(), + "method": self.command, + "path": url.path, + "query": url.query, + "headers": dict(self.headers), + "raw_bytes": len(raw), + "decoded_bytes": len(body), + } + + content_type = self.headers.get("Content-Type", "") + event_id = None + if "multipart/form-data" in content_type: + # crashpad uploads the minidump to /api//minidump/ as multipart + extension = "multipart.bin" + kind = "minidump" + else: + extension = "envelope" + kind = "envelope" + try: + header, items = parse_envelope(body) + meta["envelope_header"] = header + meta["items"] = [ + { + "type": item_header.get("type"), + "length": len(payload), + "filename": item_header.get("filename"), + "content_type": item_header.get("content_type"), + } + for item_header, payload in items + ] + event_id = header.get("event_id") + types = [i.get("type") or "unknown" for i, _ in items] + if types: + kind = "+".join(dict.fromkeys(types)) + except Exception as error: + meta["parse_error"] = str(error) + + name = f"{seq:03d}-{safe(platform_name)}-{safe(current_label)}-{safe(kind)}" + (output_dir / f"{name}.{extension}").write_bytes(body) + (output_dir / f"{name}.meta.json").write_text(json.dumps(meta, indent=2)) + with state_lock: + with (output_dir / "index.jsonl").open("a") as index: + index.write(json.dumps({"file": f"{name}.{extension}", **meta}) + "\n") + + print(f"captured {name}.{extension} ({len(body)} bytes) {url.path}", file=sys.stderr) + self.respond(200, json.dumps({"id": event_id or uuid.uuid4().hex}).encode()) + + +def main(): + global output_dir, platform_name + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, default=8000) + parser.add_argument("--output", required=True) + parser.add_argument("--platform", default="unknown") + args = parser.parse_args() + + output_dir = Path(args.output) + output_dir.mkdir(parents=True, exist_ok=True) + platform_name = args.platform + + server = ThreadingHTTPServer((args.host, args.port), Handler) + print(f"envelope capture listening on {args.host}:{args.port} -> {output_dir}", file=sys.stderr) + server.serve_forever() + print(f"envelope capture stopped after {sequence} requests", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/test/Scripts.Integration.Test/start-capture-server.ps1 b/test/Scripts.Integration.Test/start-capture-server.ps1 new file mode 100644 index 000000000..39201ee3a --- /dev/null +++ b/test/Scripts.Integration.Test/start-capture-server.ps1 @@ -0,0 +1,42 @@ +#!/usr/bin/env pwsh +# +# Starts envelope-capture-server.py in the background and waits until it serves. +# Used by the integration test workflows when SENTRY_DSN points at the capture host. + +param( + [string] $Platform = "unknown", + [int] $Port = 8000, + [string] $Output = "" +) + +$ErrorActionPreference = "Stop" + +# One directory per platform so the per-job artifacts can be merged into a single corpus +# without index.jsonl and capture-server.log colliding. +if ([string]::IsNullOrEmpty($Output)) { + $Output = "test/IntegrationTest/envelopes/$Platform" +} + +$python = if (Get-Command python3 -ErrorAction SilentlyContinue) { "python3" } else { "python" } +$server = Join-Path $PSScriptRoot "envelope-capture-server.py" + +New-Item -ItemType Directory -Force -Path $Output | Out-Null +$logPath = Join-Path $Output "capture-server.log" + +Start-Process -FilePath $python ` + -ArgumentList @($server, "--output", $Output, "--port", $Port, "--platform", $Platform) ` + -RedirectStandardError $logPath -NoNewWindow + +for ($i = 1; $i -le 30; $i++) { + try { + Invoke-WebRequest -Uri "http://127.0.0.1:$Port/HEALTH" -TimeoutSec 2 -UseBasicParsing | Out-Null + Write-Host "Envelope capture server is up on port $Port (writing to $Output)" + exit 0 + } + catch { + Start-Sleep -Seconds 1 + } +} + +Get-Content $logPath -ErrorAction SilentlyContinue | Write-Host +throw "Envelope capture server did not come up on port $Port" From 83d560027ceaccfe3b346ddb04585b4e4931e856 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 13 Aug 2026 18:32:48 +0200 Subject: [PATCH 2/7] fix capture port collision and sentry-cli url --- .github/workflows/ci.yml | 24 ++--- .github/workflows/test-run-android.yml | 10 +- .github/workflows/test-run-desktop.yml | 6 +- .github/workflows/test-run-ios.yml | 6 +- .github/workflows/test-run-webgl.yml | 9 +- docs/envelope-capture.md | 91 +++++++++++++++++++ test/IntegrationTest/Integration.Tests.ps1 | 2 +- .../Scripts/CliConfiguration.cs | 5 + .../envelope-capture-server.py | 8 +- .../start-capture-server.ps1 | 2 +- 10 files changed, 136 insertions(+), 27 deletions(-) create mode 100644 docs/envelope-capture.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcc52bb5b..f1e0af370 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -238,7 +238,7 @@ jobs: run: ./test/Scripts.Integration.Test/configure-sentry.ps1 -UnityPath "$env:UNITY_PATH" -Platform "$env:BUILD_PLATFORM" env: BUILD_PLATFORM: ${{ matrix.build_platform }} - SENTRY_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md + SENTRY_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md - name: Build Project run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform "$env:BUILD_PLATFORM" -UnityVersion "$env:UNITY_VERSION" @@ -294,7 +294,7 @@ jobs: secrets: UNITY_LICENSE_SERVER_CONFIG: ${{ secrets.UNITY_LICENSE_SERVER_CONFIG }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md + SENTRY_TEST_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: ${{ fromJSON(needs.create-unity-matrix.outputs.unity-matrix) }} @@ -308,7 +308,7 @@ jobs: needs: [test-build-android, create-unity-matrix] secrets: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md + SENTRY_TEST_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md uses: ./.github/workflows/test-run-android.yml with: unity-version: ${{ matrix.unity-version }} @@ -329,7 +329,7 @@ jobs: secrets: UNITY_LICENSE_SERVER_CONFIG: ${{ secrets.UNITY_LICENSE_SERVER_CONFIG }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md + SENTRY_TEST_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: ${{ fromJSON(needs.create-unity-matrix.outputs.unity-matrix) }} @@ -362,7 +362,7 @@ jobs: init-type: ${{ matrix.init-type }} secrets: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md + SENTRY_TEST_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -386,7 +386,7 @@ jobs: needs: [test-build-webgl, create-unity-matrix] secrets: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md + SENTRY_TEST_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -402,7 +402,7 @@ jobs: secrets: UNITY_LICENSE_SERVER_CONFIG: ${{ secrets.UNITY_LICENSE_SERVER_CONFIG }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md + SENTRY_TEST_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -418,7 +418,7 @@ jobs: secrets: UNITY_LICENSE_SERVER_CONFIG: ${{ secrets.UNITY_LICENSE_SERVER_CONFIG }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md + SENTRY_TEST_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -434,7 +434,7 @@ jobs: secrets: UNITY_LICENSE_SERVER_CONFIG: ${{ secrets.UNITY_LICENSE_SERVER_CONFIG }} SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md + SENTRY_TEST_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -449,7 +449,7 @@ jobs: needs: [test-build-linux, create-unity-matrix] secrets: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md + SENTRY_TEST_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -467,7 +467,7 @@ jobs: needs: [test-build-windows, create-unity-matrix] secrets: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md + SENTRY_TEST_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: @@ -485,7 +485,7 @@ jobs: needs: [test-build-macos, create-unity-matrix] secrets: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_TEST_DSN: http://capture@127.0.0.1:8000/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md + SENTRY_TEST_DSN: http://capture@127.0.0.1:8787/1 # TEMPORARY: envelope capture, see docs/envelope-capture.md strategy: fail-fast: false matrix: diff --git a/.github/workflows/test-run-android.yml b/.github/workflows/test-run-android.yml index 9a5caf4de..176528713 100644 --- a/.github/workflows/test-run-android.yml +++ b/.github/workflows/test-run-android.yml @@ -85,7 +85,7 @@ jobs: touch $HOME/.android/repositories.cfg # Envelope capture: only active when ci.yml was dispatched with `capture-envelopes`, which - # points the DSN baked into the test build at 127.0.0.1:8000. `adb reverse` below tunnels that + # points the DSN baked into the test build at 127.0.0.1:8787. `adb reverse` below tunnels that # port from the device to the runner. See docs/envelope-capture.md. - name: Start envelope capture server if: contains(env.SENTRY_DSN, '127.0.0.1') @@ -118,7 +118,7 @@ jobs: adb wait-for-device adb shell input keyevent 82 adb devices -l - adb reverse tcp:8000 tcp:8000 + adb reverse tcp:8787 tcp:8787 pwsh -Command '$env:SENTRY_TEST_PLATFORM = "Android"; $env:SENTRY_TEST_APP = "samples/IntegrationTest/Build/test.apk"; Invoke-Pester -Path test/IntegrationTest/Integration.Tests.ps1 -CI' # --- arm64 path: Redroid container on the arm64 runner (no hypervisor needed) --- @@ -176,14 +176,16 @@ jobs: Start-Sleep -Seconds 5 } adb devices -l - adb reverse tcp:8000 tcp:8000 + adb reverse tcp:8787 tcp:8787 $env:SENTRY_TEST_PLATFORM = "Android" $env:SENTRY_TEST_APP = "samples/IntegrationTest/Build/test.apk" Invoke-Pester -Path test/IntegrationTest/Integration.Tests.ps1 -CI - name: Stop envelope capture server if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} - run: Invoke-WebRequest -Uri "http://127.0.0.1:8000/STOP" -TimeoutSec 5 -UseBasicParsing -ErrorAction SilentlyContinue | Out-Null + run: | + try { Invoke-WebRequest -Uri "http://127.0.0.1:8787/STOP" -TimeoutSec 5 -UseBasicParsing | Out-Null } + catch { Write-Host "Capture server already gone" } - name: Upload captured envelopes if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} diff --git a/.github/workflows/test-run-desktop.yml b/.github/workflows/test-run-desktop.yml index d00202a28..14b1312a1 100644 --- a/.github/workflows/test-run-desktop.yml +++ b/.github/workflows/test-run-desktop.yml @@ -59,7 +59,7 @@ jobs: shell: bash # Envelope capture: only active when ci.yml was dispatched with `capture-envelopes`, which - # points the DSN baked into the test build at 127.0.0.1:8000. See docs/envelope-capture.md. + # points the DSN baked into the test build at 127.0.0.1:8787. See docs/envelope-capture.md. - name: Start envelope capture server if: contains(env.SENTRY_DSN, '127.0.0.1') run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "${{ inputs.platform }}${{ inputs.backend && format('-{0}', inputs.backend) || '' }}-${{ inputs.unity-version }}" @@ -94,7 +94,9 @@ jobs: - name: Stop envelope capture server if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} - run: Invoke-WebRequest -Uri "http://127.0.0.1:8000/STOP" -TimeoutSec 5 -UseBasicParsing -ErrorAction SilentlyContinue | Out-Null + run: | + try { Invoke-WebRequest -Uri "http://127.0.0.1:8787/STOP" -TimeoutSec 5 -UseBasicParsing | Out-Null } + catch { Write-Host "Capture server already gone" } - name: Upload captured envelopes if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} diff --git a/.github/workflows/test-run-ios.yml b/.github/workflows/test-run-ios.yml index fb44bc5d3..5510181a9 100644 --- a/.github/workflows/test-run-ios.yml +++ b/.github/workflows/test-run-ios.yml @@ -66,7 +66,7 @@ jobs: xcode-version: '15.0' # to run iOS 17.0 we need Xcode 15.0 # Envelope capture: only active when ci.yml was dispatched with `capture-envelopes`, which - # points the DSN baked into the test build at 127.0.0.1:8000. The simulator shares the host + # points the DSN baked into the test build at 127.0.0.1:8787. The simulator shares the host # loopback, so no port forwarding is needed. See docs/envelope-capture.md. - name: Start envelope capture server if: contains(env.SENTRY_DSN, '127.0.0.1') @@ -85,7 +85,9 @@ jobs: - name: Stop envelope capture server if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} - run: Invoke-WebRequest -Uri "http://127.0.0.1:8000/STOP" -TimeoutSec 5 -UseBasicParsing -ErrorAction SilentlyContinue | Out-Null + run: | + try { Invoke-WebRequest -Uri "http://127.0.0.1:8787/STOP" -TimeoutSec 5 -UseBasicParsing | Out-Null } + catch { Write-Host "Capture server already gone" } - name: Upload captured envelopes if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} diff --git a/.github/workflows/test-run-webgl.yml b/.github/workflows/test-run-webgl.yml index 51c6e2dbb..f2626708f 100644 --- a/.github/workflows/test-run-webgl.yml +++ b/.github/workflows/test-run-webgl.yml @@ -44,8 +44,9 @@ jobs: shell: bash # Envelope capture: only active when ci.yml was dispatched with `capture-envelopes`, which - # points the DSN baked into the test build at 127.0.0.1:8000. The capture server answers CORS - # preflights so the browser can post to it cross-origin. See docs/envelope-capture.md. + # points the DSN baked into the test build at 127.0.0.1:8787. The capture server answers CORS + # preflights so the browser can post to it cross-origin, and stays off port 8000 because + # webgl-server.py serves the build there. See docs/envelope-capture.md. - name: Start envelope capture server if: contains(env.SENTRY_DSN, '127.0.0.1') run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "webgl-${{ inputs.unity-version }}" @@ -60,7 +61,9 @@ jobs: - name: Stop envelope capture server if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} - run: Invoke-WebRequest -Uri "http://127.0.0.1:8000/STOP" -TimeoutSec 5 -UseBasicParsing -ErrorAction SilentlyContinue | Out-Null + run: | + try { Invoke-WebRequest -Uri "http://127.0.0.1:8787/STOP" -TimeoutSec 5 -UseBasicParsing | Out-Null } + catch { Write-Host "Capture server already gone" } - name: Upload captured envelopes if: ${{ always() && contains(env.SENTRY_DSN, '127.0.0.1') }} diff --git a/docs/envelope-capture.md b/docs/envelope-capture.md new file mode 100644 index 000000000..5cd25f8fc --- /dev/null +++ b/docs/envelope-capture.md @@ -0,0 +1,91 @@ +# Capturing a raw envelope corpus from the integration tests + +The integration tests exercise every managed error, native crash and app hang path the SDK has, on +every platform we ship. Capture mode records what those runs actually put on the wire - raw +envelopes, minidump uploads and all - so the payloads can be replayed against a local Sentry instead +of being sent to sentry.io. + +Two pieces: + +| | | +|---|---| +| [`test/Scripts.Integration.Test/envelope-capture-server.py`](../test/Scripts.Integration.Test/envelope-capture-server.py) | Stands in for the ingest endpoint. Writes every request to disk and answers `200`. | +| [`scripts/replay-envelopes.py`](../scripts/replay-envelopes.py) | Posts a captured corpus to a DSN of your choice. | + +## Capturing from CI + +**This is a temporary branch change, not a feature.** `ci.yml` hardcodes the DSN to +`http://capture@127.0.0.1:8787/1` in place of `SENTRY_TEST_DSN`, because the DSN is baked into +`SentryOptions.asset` at build time and so has to be switched for the whole pipeline. There is +nothing to toggle: push the branch, open the PR, wait for CI, fetch the artifact. Revert the commit +once you have the corpus. + +Every run job: + +1. starts the capture server on `127.0.0.1:8787` (`adb reverse` tunnels the port on Android; the iOS + simulator and WebGL's headless Chrome share the runner's loopback), +2. runs the usual test actions, marking each captured file with the action it belongs to, +3. uploads its own `envelopes--` artifact. + +The `collect-envelopes` job merges all of them into a single **`envelopes-all`** artifact - one +download for the whole matrix - and writes a per-platform payload count to the run summary: + +```bash +gh run download -n envelopes-all -D ./corpus +``` + +Two things the capture DSN would otherwise break, both handled: + +- `webgl-server.py` serves the WebGL build on port 8000, so the capture server listens on **8787**. +- sentry-cli takes its upload URL from the DSN whenever that DSN is not sentry.io + ([`SentryCli.UrlOverride`](../src/Sentry.Unity.Editor/SentryCli.cs)), which would point symbol + upload at the capture server. `CliConfiguration` pins `UrlOverride` to `https://sentry.io`. + +**The integration tests fail by design in capture mode.** There is no backend to verify against, so +`Integration.Tests.ps1` skips the Sentry API lookups and every event assertion fails. The artifacts +are the deliverable; a red run is expected. + +Coverage per matrix entry: `message-capture`, `exception-capture`, `crash-capture` (+ the +`crash-send` relaunch that flushes the crash envelope) and `app-hang-capture`, each of which also +emits logs, metrics, sessions and a transaction. Windows/macOS/Linux run twice, once per crash +backend (`crashpad`/`breakpad`/`native`/`cocoa`), so the corpus covers each native payload shape. + +## Capturing locally + +```bash +python3 test/Scripts.Integration.Test/envelope-capture-server.py --output ./envelopes --platform macos +SENTRY_DSN="http://capture@127.0.0.1:8787/1" \ + ./test/Scripts.Integration.Test/dev-integration-test.ps1 -UnityVersion 6000.2 -Platform MacOS +``` + +Any DSN whose host is `127.0.0.1`, `localhost` or `10.0.2.2` puts `Integration.Tests.ps1` into +capture mode. Note that this also applies when you point the tests straight at a locally running +Sentry - the run works, but the API verification is skipped. + +## What lands on disk + +One directory per matrix entry, so the merged corpus stays collision-free: + +``` +macos-cocoa-6000.2/001-macos-cocoa-6000.2-exception-capture-event_attachment.envelope # raw bytes, gunzipped +macos-cocoa-6000.2/001-macos-cocoa-6000.2-exception-capture-event_attachment.meta.json # path, headers, item types +windows-crashpad-6000.2/003-...-crash-capture-minidump.multipart.bin # crashpad minidump upload +windows-crashpad-6000.2/index.jsonl # one line per request +windows-crashpad-6000.2/capture-server.log +``` + +## Replaying into a local Sentry + +```bash +python3 scripts/replay-envelopes.py ./envelopes --dsn http://@localhost:9000/1 +python3 scripts/replay-envelopes.py ./envelopes --dsn ... --include '*crash*' --dry-run +``` + +Each envelope is rewritten before it is posted: the DSN in the envelope header is swapped for the +target, `sent_at` is set to now, event ids are regenerated and all timestamps are shifted to now +while keeping their relative offsets (breadcrumbs, spans, session start). That keeps a corpus +replayable indefinitely without deduplicating against itself or falling outside the ingest window. +Pass `--keep-ids` / `--keep-timestamps` to replay the bytes as they were captured. + +Minidump uploads are replayed verbatim to `/api//minidump/` with only the ingest key +swapped - the event ids inside the multipart body are left alone. diff --git a/test/IntegrationTest/Integration.Tests.ps1 b/test/IntegrationTest/Integration.Tests.ps1 index d5c267141..7d0d68e08 100644 --- a/test/IntegrationTest/Integration.Tests.ps1 +++ b/test/IntegrationTest/Integration.Tests.ps1 @@ -145,7 +145,7 @@ BeforeAll { } try { - Invoke-WebRequest -Uri "http://127.0.0.1:8000/MARK?label=$Label" -TimeoutSec 5 -UseBasicParsing | Out-Null + Invoke-WebRequest -Uri "http://127.0.0.1:8787/MARK?label=$Label" -TimeoutSec 5 -UseBasicParsing | Out-Null } catch { Write-Host "Failed to mark capture label '$Label': $_" diff --git a/test/Scripts.Integration.Test/Scripts/CliConfiguration.cs b/test/Scripts.Integration.Test/Scripts/CliConfiguration.cs index 89b72cd1f..d6ecbeaa5 100644 --- a/test/Scripts.Integration.Test/Scripts/CliConfiguration.cs +++ b/test/Scripts.Integration.Test/Scripts/CliConfiguration.cs @@ -16,6 +16,11 @@ public override void Configure(SentryCliOptions cliOptions) cliOptions.Organization = "sentry-sdks"; cliOptions.Project = "sentry-unity-integration-tests"; + // sentry-cli derives its upload URL from the DSN whenever that DSN is not sentry.io. During + // envelope capture the DSN points at the local capture server, which would send symbol + // uploads there too. Pin the CLI to sentry.io so symbol upload keeps working either way. + cliOptions.UrlOverride = "https://sentry.io"; + Debug.Log("Sentry: CliConfiguration::Configure() finished"); } } diff --git a/test/Scripts.Integration.Test/envelope-capture-server.py b/test/Scripts.Integration.Test/envelope-capture-server.py index 4ce6834b1..b44b57c78 100644 --- a/test/Scripts.Integration.Test/envelope-capture-server.py +++ b/test/Scripts.Integration.Test/envelope-capture-server.py @@ -8,7 +8,7 @@ against a local Sentry via scripts/replay-envelopes.py. Usage: - envelope-capture-server.py --output DIR [--host 0.0.0.0] [--port 8000] [--platform NAME] + envelope-capture-server.py --output DIR [--host 0.0.0.0] [--port 8787] [--platform NAME] Control endpoints: GET /HEALTH 200 once the server is serving @@ -98,6 +98,10 @@ def respond(self, code, payload=b"", content_type="application/json"): self.send_response(code) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(payload))) + # One request per connection. Keep-alive sockets that the server later drops surface as + # "the network connection was lost" in NSURLSession and cost us envelopes. + self.send_header("Connection", "close") + self.close_connection = True self.cors() self.end_headers() if payload: @@ -203,7 +207,7 @@ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--host", default="0.0.0.0") - parser.add_argument("--port", type=int, default=8000) + parser.add_argument("--port", type=int, default=8787) parser.add_argument("--output", required=True) parser.add_argument("--platform", default="unknown") args = parser.parse_args() diff --git a/test/Scripts.Integration.Test/start-capture-server.ps1 b/test/Scripts.Integration.Test/start-capture-server.ps1 index 39201ee3a..53ceb3e6a 100644 --- a/test/Scripts.Integration.Test/start-capture-server.ps1 +++ b/test/Scripts.Integration.Test/start-capture-server.ps1 @@ -5,7 +5,7 @@ param( [string] $Platform = "unknown", - [int] $Port = 8000, + [int] $Port = 8787, [string] $Output = "" ) From 4a1329d83bea49bb7cddc22134ce961c95bab9f9 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Thu, 13 Aug 2026 19:12:25 +0200 Subject: [PATCH 3/7] capture debug files via sentry-cli mock --- .github/workflows/ci.yml | 106 ---------------- .github/workflows/test-build-android.yml | 55 +++------ .github/workflows/test-build-ios.yml | 40 ------ .github/workflows/test-build-linux.yml | 57 +++------ .github/workflows/test-build-macos.yml | 57 +++------ .github/workflows/test-build-windows.yml | 57 +++------ .github/workflows/test-compile-ios.yml | 66 +++------- docs/envelope-capture.md | 105 +++++++++------- scripts/ci-docker.sh | 2 +- .../Scripts/CliConfiguration.cs | 6 +- .../envelope-capture-server.py | 114 +++++++++++++++++- 11 files changed, 272 insertions(+), 393 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1e0af370..ec69ae5ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,46 +73,6 @@ jobs: with: unity-version: ${{ matrix.unity-version }} - package-validation: - name: UPM Package validation - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ github.head_ref || github.ref }} - ssh-key: ${{ secrets.CI_DEPLOY_KEY }} - - - name: Download UPM package - uses: ./.github/actions/wait-for-artifact - with: - name: package-release - - - name: Check snapshot - id: snapshot-check - shell: pwsh - run: | - $ErrorActionPreference = 'Continue' - & ./test/Scripts.Tests/test-pack-contents.ps1 - "result=$LASTEXITCODE" >> $env:GITHUB_OUTPUT - - - name: Update snapshot for dependency bumps - if: steps.snapshot-check.outputs.result != '0' && github.event_name == 'pull_request' && startsWith(github.head_ref, 'deps/') - run: | - ./test/Scripts.Tests/test-pack-contents.ps1 accept - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add test/Scripts.Tests/package-release.zip.snapshot - git commit -m "Update package release snapshot" - git push - - - name: Fail on unexpected snapshot changes - if: steps.snapshot-check.outputs.result != '0' && !(github.event_name == 'pull_request' && startsWith(github.head_ref, 'deps/')) - run: | - Write-Error "Package contents do not match snapshot. Run './test/Scripts.Tests/test-pack-contents.ps1 accept' locally and commit the update." - exit 1 - - # This produces the `samples/IntegrationTest` as `test-${{ matrix.unity-version }}`. test-create: name: Create ${{ matrix.unity-version }} Test Project if: ${{ !startsWith(github.ref, 'refs/heads/release/') }} @@ -189,26 +149,6 @@ jobs: restore-keys: | it-library-ubuntu-${{ matrix.unity-version }}- - - name: Restore cached build without Sentry - id: cache-build-nosentry - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-${{ matrix.build_platform }}-${{ matrix.unity-version }} - - - name: Build without Sentry SDK - if: steps.cache-build-nosentry.outputs.cache-hit != 'true' - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform "$env:BUILD_PLATFORM" -BuildDirName "Build-NoSentry" - env: - BUILD_PLATFORM: ${{ matrix.build_platform }} - - - name: Save cached build without Sentry - if: github.ref == 'refs/heads/main' && steps.cache-build-nosentry.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-${{ matrix.build_platform }}-${{ matrix.unity-version }} - - name: Download UPM package uses: ./.github/actions/wait-for-artifact with: @@ -220,20 +160,6 @@ jobs: - name: Add Sentry to the project run: ./test/Scripts.Integration.Test/add-sentry.ps1 -UnityPath "$env:UNITY_PATH" -PackagePath "test-package-release" - - name: Download DependencyConflict package - uses: ./.github/actions/wait-for-artifact - with: - name: dependency-conflict-package - path: dependency-conflict-package - - - name: Add DependencyConflict to the project - if: ${{ !startsWith(matrix.unity-version, '2021') }} - run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -PackagePath "dependency-conflict-package" - - - name: Disable DependencyConflict (WebGL 2021) - if: ${{ startsWith(matrix.unity-version, '2021') }} - run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -Disable - - name: Configure Sentry run: ./test/Scripts.Integration.Test/configure-sentry.ps1 -UnityPath "$env:UNITY_PATH" -Platform "$env:BUILD_PLATFORM" env: @@ -246,20 +172,6 @@ jobs: BUILD_PLATFORM: ${{ matrix.build_platform }} UNITY_VERSION: ${{ matrix.unity-version }} - - name: Compare build sizes - run: ./test/Scripts.Integration.Test/measure-build-size.ps1 -Path1 "samples/IntegrationTest/Build-NoSentry" -Path2 "samples/IntegrationTest/Build" -Platform "$env:BUILD_PLATFORM" -UnityVersion "$env:UNITY_VERSION" - env: - BUILD_PLATFORM: ${{ matrix.build_platform }} - UNITY_VERSION: ${{ matrix.unity-version }} - - - name: Upload build size measurement - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: build-size-${{ matrix.platform }}-${{ matrix.unity-version }} - path: build-size-measurements/*.json - retention-days: 1 - - # We create tar explicitly because upload-artifact is slow for many files. - name: Create archive run: | Remove-Item -Recurse -Force samples/IntegrationTest/Build/*_BackUpThisFolder_ButDontShipItWithYourGame -ErrorAction SilentlyContinue @@ -534,21 +446,3 @@ jobs: if-no-files-found: error retention-days: 14 - build-size-summary: - name: Build Size - runs-on: ubuntu-latest - if: ${{ !startsWith(github.ref, 'refs/heads/release/') }} - needs: [test-build-webgl, test-build-android, test-compile-ios, test-build-linux, test-build-windows, test-build-macos] - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Download all build size measurements - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - pattern: build-size-* - path: build-size-measurements - - - name: Create consolidated summary - shell: pwsh - run: ./scripts/create-build-size-summary.ps1 \ No newline at end of file diff --git a/.github/workflows/test-build-android.yml b/.github/workflows/test-build-android.yml index 1524018ed..510a3431f 100644 --- a/.github/workflows/test-build-android.yml +++ b/.github/workflows/test-build-android.yml @@ -60,24 +60,6 @@ jobs: restore-keys: | it-library-ubuntu-${{ env.UNITY_VERSION }}- - - name: Restore cached build without Sentry - id: cache-build-nosentry - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-Android-${{ inputs.unity-version }} - - - name: Build without Sentry SDK - if: steps.cache-build-nosentry.outputs.cache-hit != 'true' - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform "Android" -BuildDirName "Build-NoSentry" - - - name: Save cached build without Sentry - if: github.ref == 'refs/heads/main' && steps.cache-build-nosentry.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-Android-${{ inputs.unity-version }} - - name: Download UPM package uses: ./.github/actions/wait-for-artifact with: @@ -89,14 +71,10 @@ jobs: - name: Add Sentry to the project run: ./test/Scripts.Integration.Test/add-sentry.ps1 -UnityPath "$env:UNITY_PATH" -PackagePath "test-package-release" - - name: Download DependencyConflict package - uses: ./.github/actions/wait-for-artifact - with: - name: dependency-conflict-package - path: dependency-conflict-package - - - name: Add DependencyConflict to the project - run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -PackagePath "dependency-conflict-package" + # Envelope capture: sentry-cli takes its upload URL from the DSN, so every debug file it + # uploads during the build lands in the capture output. See docs/envelope-capture.md. + - name: Start envelope capture server + run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "android-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/android-${{ env.UNITY_VERSION }}" - name: Configure Sentry run: ./test/Scripts.Integration.Test/configure-sentry.ps1 -UnityPath "$env:UNITY_PATH" -Platform "Android" @@ -109,16 +87,6 @@ jobs: - name: Assert symbols and sources were uploaded (Runtime) run: ./test/Scripts.Integration.Test/assert-symbol-upload.ps1 -LogPath samples/IntegrationTest/Logs/sentry-symbols-upload.log - - name: Compare build sizes (Runtime) - run: ./test/Scripts.Integration.Test/measure-build-size.ps1 -Path1 "samples/IntegrationTest/Build-NoSentry" -Path2 "samples/IntegrationTest/Build" -Platform "Android" -UnityVersion "$env:UNITY_VERSION" - - - name: Upload build size measurement - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: build-size-Android-${{ env.UNITY_VERSION }} - path: build-size-measurements/*.json - retention-days: 1 - - name: Bundle build & symbol-upload logs with the APK (Runtime) run: | Copy-Item unity.log samples/IntegrationTest/Build/ -ErrorAction SilentlyContinue @@ -176,3 +144,18 @@ jobs: !samples/IntegrationTest/Build/*_BackUpThisFolder_ButDontShipItWithYourGame retention-days: 14 # Lower retention period - we only need this to retry CI. + - name: Stop envelope capture server + if: ${{ always() }} + run: | + try { Invoke-WebRequest -Uri "http://127.0.0.1:8787/STOP" -TimeoutSec 5 -UseBasicParsing | Out-Null } + catch { Write-Host "Capture server already gone" } + + - name: Upload captured debug files + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: symbols-android-${{ env.UNITY_VERSION }} + path: test/IntegrationTest/symbols/ + if-no-files-found: warn + retention-days: 14 + diff --git a/.github/workflows/test-build-ios.yml b/.github/workflows/test-build-ios.yml index 9dab592ec..c30fe38c1 100644 --- a/.github/workflows/test-build-ios.yml +++ b/.github/workflows/test-build-ios.yml @@ -66,37 +66,6 @@ jobs: restore-keys: | it-library-ubuntu-${{ env.UNITY_VERSION }}- - - name: Restore cached build without Sentry - id: cache-build-nosentry - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-iOS-${{ inputs.unity-version }} - - - name: Build without Sentry SDK - if: steps.cache-build-nosentry.outputs.cache-hit != 'true' - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform "iOS" -BuildDirName "Build-NoSentry" - - - name: Save cached build without Sentry - if: github.ref == 'refs/heads/main' && steps.cache-build-nosentry.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-iOS-${{ inputs.unity-version }} - - - name: Create archive for build without Sentry - shell: bash - run: | - rm -rf samples/IntegrationTest/Build-NoSentry/*_BackUpThisFolder_ButDontShipItWithYourGame - tar -cvzf test-app-no-sentry.tar.gz samples/IntegrationTest/Build-NoSentry - - - name: Upload build without Sentry - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: testapp-ios-no-sentry-${{ env.UNITY_VERSION }} - path: test-app-no-sentry.tar.gz - retention-days: 1 - - name: Download UPM package uses: ./.github/actions/wait-for-artifact with: @@ -108,15 +77,6 @@ jobs: - name: Add Sentry to the project run: ./test/Scripts.Integration.Test/add-sentry.ps1 -UnityPath "$env:UNITY_PATH" -PackagePath "test-package-release" - - name: Download DependencyConflict package - uses: ./.github/actions/wait-for-artifact - with: - name: dependency-conflict-package - path: dependency-conflict-package - - - name: Add DependencyConflict to the project - run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -PackagePath "dependency-conflict-package" - - name: Configure Sentry run: ./test/Scripts.Integration.Test/configure-sentry.ps1 -UnityPath "$env:UNITY_PATH" -Platform "$env:BUILD_PLATFORM" env: diff --git a/.github/workflows/test-build-linux.yml b/.github/workflows/test-build-linux.yml index 5535dc050..15cd4ff8d 100644 --- a/.github/workflows/test-build-linux.yml +++ b/.github/workflows/test-build-linux.yml @@ -71,24 +71,6 @@ jobs: restore-keys: | it-library-ubuntu-${{ env.UNITY_VERSION }}- - - name: Restore cached build without Sentry - id: cache-build-nosentry - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-Linux-${{ inputs.unity-version }} - - - name: Build without Sentry SDK - if: steps.cache-build-nosentry.outputs.cache-hit != 'true' - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform Linux -BuildDirName "Build-NoSentry" - - - name: Save cached build without Sentry - if: github.ref == 'refs/heads/main' && steps.cache-build-nosentry.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-Linux-${{ inputs.unity-version }} - - name: Download UPM package uses: ./.github/actions/wait-for-artifact with: @@ -100,14 +82,10 @@ jobs: - name: Add Sentry to the project run: ./test/Scripts.Integration.Test/add-sentry.ps1 -UnityPath "$env:UNITY_PATH" -PackagePath "test-package-release" - - name: Download DependencyConflict package - uses: ./.github/actions/wait-for-artifact - with: - name: dependency-conflict-package - path: dependency-conflict-package - - - name: Add DependencyConflict to the project - run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -PackagePath "dependency-conflict-package" + # Envelope capture: sentry-cli takes its upload URL from the DSN, so every debug file it + # uploads during the build lands in the capture output. See docs/envelope-capture.md. + - name: Start envelope capture server + run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "linux-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/linux-${{ env.UNITY_VERSION }}" - name: Configure Sentry run: ./test/Scripts.Integration.Test/configure-sentry.ps1 -UnityPath "$env:UNITY_PATH" -Platform Linux @@ -120,17 +98,6 @@ jobs: - name: Assert symbols and sources were uploaded (Breakpad backend) run: ./test/Scripts.Integration.Test/assert-symbol-upload.ps1 -LogPath unity.log - - name: Compare build sizes - run: ./test/Scripts.Integration.Test/measure-build-size.ps1 -Path1 "samples/IntegrationTest/Build-NoSentry" -Path2 "samples/IntegrationTest/Build" -Platform Linux -UnityVersion "$env:UNITY_VERSION" - - - name: Upload build size measurement - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: build-size-Linux-${{ env.UNITY_VERSION }} - path: build-size-measurements/*.json - retention-days: 1 - - # We create tar explicitly because upload-artifact is slow for many files. - name: Create archive (Breakpad backend) run: | Remove-Item -Recurse -Force samples/IntegrationTest/Build/*_BackUpThisFolder_ButDontShipItWithYourGame -ErrorAction SilentlyContinue @@ -203,3 +170,19 @@ jobs: unity.log !samples/IntegrationTest/Build/*_BackUpThisFolder_ButDontShipItWithYourGame retention-days: 14 + + - name: Stop envelope capture server + if: ${{ always() }} + run: | + try { Invoke-WebRequest -Uri "http://127.0.0.1:8787/STOP" -TimeoutSec 5 -UseBasicParsing | Out-Null } + catch { Write-Host "Capture server already gone" } + + - name: Upload captured debug files + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: symbols-linux-${{ env.UNITY_VERSION }} + path: test/IntegrationTest/symbols/ + if-no-files-found: warn + retention-days: 14 + diff --git a/.github/workflows/test-build-macos.yml b/.github/workflows/test-build-macos.yml index 318f71bd5..78840ce5b 100644 --- a/.github/workflows/test-build-macos.yml +++ b/.github/workflows/test-build-macos.yml @@ -66,24 +66,6 @@ jobs: restore-keys: | it-library-macos-${{ env.UNITY_VERSION }}- - - name: Restore cached build without Sentry - id: cache-build-nosentry - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-MacOS-${{ inputs.unity-version }} - - - name: Build without Sentry SDK - if: steps.cache-build-nosentry.outputs.cache-hit != 'true' - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform MacOS -BuildDirName "Build-NoSentry" - - - name: Save cached build without Sentry - if: github.ref == 'refs/heads/main' && steps.cache-build-nosentry.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-MacOS-${{ inputs.unity-version }} - - name: Download UPM package uses: ./.github/actions/wait-for-artifact with: @@ -95,14 +77,10 @@ jobs: - name: Add Sentry to the project run: ./test/Scripts.Integration.Test/add-sentry.ps1 -UnityPath "$env:UNITY_PATH" -PackagePath "test-package-release" - - name: Download DependencyConflict package - uses: ./.github/actions/wait-for-artifact - with: - name: dependency-conflict-package - path: dependency-conflict-package - - - name: Add DependencyConflict to the project - run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -PackagePath "dependency-conflict-package" + # Envelope capture: sentry-cli takes its upload URL from the DSN, so every debug file it + # uploads during the build lands in the capture output. See docs/envelope-capture.md. + - name: Start envelope capture server + run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "macos-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/macos-${{ env.UNITY_VERSION }}" - name: Configure Sentry run: ./test/Scripts.Integration.Test/configure-sentry.ps1 -UnityPath "$env:UNITY_PATH" -Platform MacOS @@ -117,17 +95,6 @@ jobs: - name: Assert symbols and sources were uploaded (Cocoa backend) run: ./test/Scripts.Integration.Test/assert-symbol-upload.ps1 -LogPath unity.log - - name: Compare build sizes - run: ./test/Scripts.Integration.Test/measure-build-size.ps1 -Path1 "samples/IntegrationTest/Build-NoSentry" -Path2 "samples/IntegrationTest/Build" -Platform MacOS -UnityVersion "$env:UNITY_VERSION" - - - name: Upload build size measurement - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: build-size-MacOS-${{ env.UNITY_VERSION }} - path: build-size-measurements/*.json - retention-days: 1 - - # We create tar explicitly because upload-artifact is slow for many files. - name: Create archive (Cocoa backend) run: | Remove-Item -Recurse -Force samples/IntegrationTest/Build/*_BackUpThisFolder_ButDontShipItWithYourGame -ErrorAction SilentlyContinue @@ -189,3 +156,19 @@ jobs: unity.log !samples/IntegrationTest/Build/*_BackUpThisFolder_ButDontShipItWithYourGame retention-days: 14 + + - name: Stop envelope capture server + if: ${{ always() }} + run: | + try { Invoke-WebRequest -Uri "http://127.0.0.1:8787/STOP" -TimeoutSec 5 -UseBasicParsing | Out-Null } + catch { Write-Host "Capture server already gone" } + + - name: Upload captured debug files + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: symbols-macos-${{ env.UNITY_VERSION }} + path: test/IntegrationTest/symbols/ + if-no-files-found: warn + retention-days: 14 + diff --git a/.github/workflows/test-build-windows.yml b/.github/workflows/test-build-windows.yml index 89dedc474..cba3c0d94 100644 --- a/.github/workflows/test-build-windows.yml +++ b/.github/workflows/test-build-windows.yml @@ -66,24 +66,6 @@ jobs: restore-keys: | it-library-windows-${{ env.UNITY_VERSION }}- - - name: Restore cached build without Sentry - id: cache-build-nosentry - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-Windows-${{ inputs.unity-version }} - - - name: Build without Sentry SDK - if: steps.cache-build-nosentry.outputs.cache-hit != 'true' - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform Windows -BuildDirName "Build-NoSentry" - - - name: Save cached build without Sentry - if: github.ref == 'refs/heads/main' && steps.cache-build-nosentry.outputs.cache-hit != 'true' - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: samples/IntegrationTest/Build-NoSentry - key: build-nosentry-Windows-${{ inputs.unity-version }} - - name: Download UPM package uses: ./.github/actions/wait-for-artifact with: @@ -95,14 +77,10 @@ jobs: - name: Add Sentry to the project run: ./test/Scripts.Integration.Test/add-sentry.ps1 -UnityPath "$env:UNITY_PATH" -PackagePath "test-package-release" - - name: Download DependencyConflict package - uses: ./.github/actions/wait-for-artifact - with: - name: dependency-conflict-package - path: dependency-conflict-package - - - name: Add DependencyConflict to the project - run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -PackagePath "dependency-conflict-package" + # Envelope capture: sentry-cli takes its upload URL from the DSN, so every debug file it + # uploads during the build lands in the capture output. See docs/envelope-capture.md. + - name: Start envelope capture server + run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "windows-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/windows-${{ env.UNITY_VERSION }}" - name: Configure Sentry run: ./test/Scripts.Integration.Test/configure-sentry.ps1 -UnityPath "$env:UNITY_PATH" -Platform Windows @@ -117,17 +95,6 @@ jobs: - name: Assert symbols and sources were uploaded (Crashpad backend) run: ./test/Scripts.Integration.Test/assert-symbol-upload.ps1 -LogPath unity.log - - name: Compare build sizes - run: ./test/Scripts.Integration.Test/measure-build-size.ps1 -Path1 "samples/IntegrationTest/Build-NoSentry" -Path2 "samples/IntegrationTest/Build" -Platform Windows -UnityVersion "$env:UNITY_VERSION" - - - name: Upload build size measurement - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: build-size-Windows-${{ env.UNITY_VERSION }} - path: build-size-measurements/*.json - retention-days: 1 - - # We create tar explicitly because upload-artifact is slow for many files. - name: Create archive (Crashpad backend) run: | Remove-Item -Recurse -Force samples/IntegrationTest/Build/*_BackUpThisFolder_ButDontShipItWithYourGame -ErrorAction SilentlyContinue @@ -189,3 +156,19 @@ jobs: unity.log !samples/IntegrationTest/Build/*_BackUpThisFolder_ButDontShipItWithYourGame retention-days: 14 + + - name: Stop envelope capture server + if: ${{ always() }} + run: | + try { Invoke-WebRequest -Uri "http://127.0.0.1:8787/STOP" -TimeoutSec 5 -UseBasicParsing | Out-Null } + catch { Write-Host "Capture server already gone" } + + - name: Upload captured debug files + if: ${{ always() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: symbols-windows-${{ env.UNITY_VERSION }} + path: test/IntegrationTest/symbols/ + if-no-files-found: warn + retention-days: 14 + diff --git a/.github/workflows/test-compile-ios.yml b/.github/workflows/test-compile-ios.yml index 74f58a129..38d282313 100644 --- a/.github/workflows/test-compile-ios.yml +++ b/.github/workflows/test-compile-ios.yml @@ -25,45 +25,6 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Restore cached compiled iOS build without Sentry - if: ${{ inputs.init-type == 'runtime' }} - id: cache-compiled-nosentry - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: IntegrationTest-NoSentry.app - key: build-nosentry-iOS-compiled-${{ inputs.unity-version }} - - - name: Download build without Sentry (for size comparison) - if: ${{ inputs.init-type == 'runtime' && steps.cache-compiled-nosentry.outputs.cache-hit != 'true' }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: testapp-ios-no-sentry-${{ env.UNITY_VERSION }} - - - name: Extract and compile build without Sentry - if: ${{ inputs.init-type == 'runtime' && steps.cache-compiled-nosentry.outputs.cache-hit != 'true' }} - run: | - tar -xvzf test-app-no-sentry.tar.gz - Move-Item -Path "samples/IntegrationTest/Build-NoSentry" -Destination "samples/IntegrationTest/Build" - - - name: Compile Xcode project without Sentry - if: ${{ inputs.init-type == 'runtime' && steps.cache-compiled-nosentry.outputs.cache-hit != 'true' }} - run: ./scripts/compile-xcode-project.ps1 -iOSMinVersion "17.0" - timeout-minutes: 20 - - - name: Save compiled app without Sentry - if: ${{ inputs.init-type == 'runtime' && steps.cache-compiled-nosentry.outputs.cache-hit != 'true' }} - run: | - Copy-Item -Path "samples/IntegrationTest/Build/archive/Unity-iPhone/Build/Products/Release-iphonesimulator/IntegrationTest.app" ` - -Destination "IntegrationTest-NoSentry.app" -Recurse - Remove-Item -Path "samples/IntegrationTest/Build" -Recurse -Force - - - name: Save cached compiled iOS build without Sentry - if: ${{ inputs.init-type == 'runtime' && steps.cache-compiled-nosentry.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main' }} - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - with: - path: IntegrationTest-NoSentry.app - key: build-nosentry-iOS-compiled-${{ inputs.unity-version }} - - name: Download app project uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -72,6 +33,11 @@ jobs: - name: Extract app project run: tar -xvzf "test-app-$env:INIT_TYPE.tar.gz" + # Envelope capture: sentry-cli takes its upload URL from the DSN, so every debug file it + # uploads during the build lands in the capture output. See docs/envelope-capture.md. + - name: Start envelope capture server + run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "ios-${{ env.UNITY_VERSION }}-${{ env.INIT_TYPE }}" -Output "test/IntegrationTest/symbols/ios-${{ env.UNITY_VERSION }}-${{ env.INIT_TYPE }}" + - name: iOS test run: ./scripts/compile-xcode-project.ps1 -iOSMinVersion "17.0" timeout-minutes: 20 @@ -106,19 +72,17 @@ jobs: # Lower retention period - we only need this to retry CI. retention-days: 14 - - name: Compare build sizes - if: ${{ inputs.init-type == 'runtime' }} + - name: Stop envelope capture server + if: ${{ always() }} run: | - ./test/Scripts.Integration.Test/measure-build-size.ps1 ` - -Path1 "IntegrationTest-NoSentry.app" ` - -Path2 "samples/IntegrationTest/Build/archive/Unity-iPhone/Build/Products/Release-iphonesimulator/IntegrationTest.app" ` - -Platform "iOS" ` - -UnityVersion "$env:UNITY_VERSION" + try { Invoke-WebRequest -Uri "http://127.0.0.1:8787/STOP" -TimeoutSec 5 -UseBasicParsing | Out-Null } + catch { Write-Host "Capture server already gone" } - - name: Upload build size measurement - if: ${{ inputs.init-type == 'runtime' }} + - name: Upload captured debug files + if: ${{ always() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: build-size-iOS-${{ env.UNITY_VERSION }} - path: build-size-measurements/*.json - retention-days: 1 + name: symbols-ios-${{ env.UNITY_VERSION }}-${{ env.INIT_TYPE }} + path: test/IntegrationTest/symbols/ + if-no-files-found: warn + retention-days: 14 diff --git a/docs/envelope-capture.md b/docs/envelope-capture.md index 5cd25f8fc..c21897253 100644 --- a/docs/envelope-capture.md +++ b/docs/envelope-capture.md @@ -1,86 +1,93 @@ -# Capturing a raw envelope corpus from the integration tests +# Capturing a raw envelope + debug file corpus from the integration tests The integration tests exercise every managed error, native crash and app hang path the SDK has, on every platform we ship. Capture mode records what those runs actually put on the wire - raw -envelopes, minidump uploads and all - so the payloads can be replayed against a local Sentry instead -of being sent to sentry.io. - -Two pieces: +envelopes, minidump uploads, and the debug files sentry-cli uploads at build time - so the whole lot +can be replayed against a local Sentry instead of going to sentry.io. | | | |---|---| -| [`test/Scripts.Integration.Test/envelope-capture-server.py`](../test/Scripts.Integration.Test/envelope-capture-server.py) | Stands in for the ingest endpoint. Writes every request to disk and answers `200`. | -| [`scripts/replay-envelopes.py`](../scripts/replay-envelopes.py) | Posts a captured corpus to a DSN of your choice. | - -## Capturing from CI +| [`test/Scripts.Integration.Test/envelope-capture-server.py`](../test/Scripts.Integration.Test/envelope-capture-server.py) | Stands in for both Sentry endpoints: envelope ingest, and the chunk-upload API sentry-cli uses for debug files. Writes everything to disk. | +| [`scripts/replay-envelopes.py`](../scripts/replay-envelopes.py) | Posts a captured envelope corpus to a DSN of your choice. | **This is a temporary branch change, not a feature.** `ci.yml` hardcodes the DSN to -`http://capture@127.0.0.1:8787/1` in place of `SENTRY_TEST_DSN`, because the DSN is baked into -`SentryOptions.asset` at build time and so has to be switched for the whole pipeline. There is -nothing to toggle: push the branch, open the PR, wait for CI, fetch the artifact. Revert the commit -once you have the corpus. +`http://capture@127.0.0.1:8787/1` in place of `SENTRY_TEST_DSN`. That one switch redirects both +halves of the capture, because the DSN is what decides where each goes: + +- the SDK sends envelopes there at **run** time, +- sentry-cli derives its upload URL from that same DSN ([`SentryCli.UrlOverride`](../src/Sentry.Unity.Editor/SentryCli.cs)), + so debug files land there at **build** time. -Every run job: +There is nothing to toggle: push the branch, open the PR, wait for CI, fetch the artifacts. Revert +the commit once you have the corpus. -1. starts the capture server on `127.0.0.1:8787` (`adb reverse` tunnels the port on Android; the iOS - simulator and WebGL's headless Chrome share the runner's loopback), -2. runs the usual test actions, marking each captured file with the action it belongs to, -3. uploads its own `envelopes--` artifact. +## What CI produces -The `collect-envelopes` job merges all of them into a single **`envelopes-all`** artifact - one -download for the whole matrix - and writes a per-platform payload count to the run summary: +| Artifact | From | Contents | +|---|---|---| +| `envelopes-all` | run jobs, merged | every envelope and minidump upload, one directory per platform | +| `symbols--` | build jobs | the debug files and source bundles sentry-cli uploaded for that build | ```bash gh run download -n envelopes-all -D ./corpus +gh run download -p 'symbols-*' -D ./corpus/symbols ``` -Two things the capture DSN would otherwise break, both handled: - -- `webgl-server.py` serves the WebGL build on port 8000, so the capture server listens on **8787**. -- sentry-cli takes its upload URL from the DSN whenever that DSN is not sentry.io - ([`SentryCli.UrlOverride`](../src/Sentry.Unity.Editor/SentryCli.cs)), which would point symbol - upload at the capture server. `CliConfiguration` pins `UrlOverride` to `https://sentry.io`. +Debug files are big (IL2CPP `GameAssembly.pdb` and friends), so they stay per-platform rather than +being merged into one download. **The integration tests fail by design in capture mode.** There is no backend to verify against, so `Integration.Tests.ps1` skips the Sentry API lookups and every event assertion fails. The artifacts -are the deliverable; a red run is expected. +are the deliverable; a red run is expected. The symbol-upload assertions are the exception and still +mean something: they pass only if sentry-cli really did upload debug files to the capture server. Coverage per matrix entry: `message-capture`, `exception-capture`, `crash-capture` (+ the `crash-send` relaunch that flushes the crash envelope) and `app-hang-capture`, each of which also emits logs, metrics, sessions and a transaction. Windows/macOS/Linux run twice, once per crash backend (`crashpad`/`breakpad`/`native`/`cocoa`), so the corpus covers each native payload shape. -## Capturing locally +Two details the capture DSN would otherwise break, both handled: `webgl-server.py` serves the WebGL +build on port 8000, so capture listens on **8787**; and the Linux/Android/WebGL builds run Unity +inside a container, so [`ci-docker.sh`](../scripts/ci-docker.sh) uses `--network host` to let the +in-container sentry-cli reach the capture server. -```bash -python3 test/Scripts.Integration.Test/envelope-capture-server.py --output ./envelopes --platform macos -SENTRY_DSN="http://capture@127.0.0.1:8787/1" \ - ./test/Scripts.Integration.Test/dev-integration-test.ps1 -UnityVersion 6000.2 -Platform MacOS -``` +### What CI no longer does on this branch -Any DSN whose host is `127.0.0.1`, `localhost` or `10.0.2.2` puts `Integration.Tests.ps1` into -capture mode. Note that this also applies when you point the tests straight at a locally running -Sentry - the run works, but the API verification is skipped. +Stripped to keep the run short and the failures meaningful: the UPM package snapshot validation, all +build-size measurement (including every "build without Sentry" pass and the `build-size-summary` +job), and the dependency-conflict package steps. ## What lands on disk One directory per matrix entry, so the merged corpus stays collision-free: ``` -macos-cocoa-6000.2/001-macos-cocoa-6000.2-exception-capture-event_attachment.envelope # raw bytes, gunzipped -macos-cocoa-6000.2/001-macos-cocoa-6000.2-exception-capture-event_attachment.meta.json # path, headers, item types -windows-crashpad-6000.2/003-...-crash-capture-minidump.multipart.bin # crashpad minidump upload -windows-crashpad-6000.2/index.jsonl # one line per request -windows-crashpad-6000.2/capture-server.log +macos-cocoa-6000.5/001-macos-cocoa-6000.5-exception-capture-event_attachment.envelope # raw bytes, gunzipped +macos-cocoa-6000.5/001-macos-cocoa-6000.5-exception-capture-event_attachment.meta.json # path, headers, item types +windows-crashpad-6000.5/003-...-crash-capture-minidump.multipart.bin # crashpad minidump upload +windows-crashpad-6000.5/index.jsonl # one line per request +symbols/macos-6000.5/debug-files/--GameAssembly.dylib # debug companion +symbols/macos-6000.5/debug-files/--GameAssembly.dylib.src # source bundle +symbols/macos-6000.5/debug-files/index.jsonl # debug id -> file ``` +A dif and its source bundle share a debug id *and* a name, so the checksum in the file name is what +keeps them apart. + ## Replaying into a local Sentry ```bash -python3 scripts/replay-envelopes.py ./envelopes --dsn http://@localhost:9000/1 -python3 scripts/replay-envelopes.py ./envelopes --dsn ... --include '*crash*' --dry-run +# events, crashes, sessions, logs +python3 scripts/replay-envelopes.py ./corpus --dsn http://@localhost:9000/1 + +# the debug files that symbolicate them +sentry-cli --url http://localhost:9000 --auth-token debug-files upload \ + -o -p ./corpus/symbols/macos-6000.5/debug-files ``` +sentry-cli reads the captured files straight out of the artifact and re-uploads them under their +original debug ids, which is what lets the replayed crashes symbolicate. + Each envelope is rewritten before it is posted: the DSN in the envelope header is swapped for the target, `sent_at` is set to now, event ids are regenerated and all timestamps are shifted to now while keeping their relative offsets (breadcrumbs, spans, session start). That keeps a corpus @@ -89,3 +96,15 @@ Pass `--keep-ids` / `--keep-timestamps` to replay the bytes as they were capture Minidump uploads are replayed verbatim to `/api//minidump/` with only the ingest key swapped - the event ids inside the multipart body are left alone. + +## Capturing locally + +```bash +python3 test/Scripts.Integration.Test/envelope-capture-server.py --output ./out --platform macos +SENTRY_DSN="http://capture@127.0.0.1:8787/1" \ + ./test/Scripts.Integration.Test/dev-integration-test.ps1 -UnityVersion 6000.5 -Platform MacOS +``` + +Any DSN whose host is `127.0.0.1`, `localhost` or `10.0.2.2` puts `Integration.Tests.ps1` into +capture mode. Note that this also applies when you point the tests straight at a locally running +Sentry - the run works, but the API verification is skipped. diff --git a/scripts/ci-docker.sh b/scripts/ci-docker.sh index a9330c0b1..c3d8cc8af 100755 --- a/scripts/ci-docker.sh +++ b/scripts/ci-docker.sh @@ -34,7 +34,7 @@ uniqueHostname=$(echo "$uniqueHostname" | tr '[:upper:]_ ' '[:lower:]--' | tr -s # We use the host dotnet installation - it's much faster than installing inside the docker container. set -x docker run -td --name $container \ - --hostname $uniqueHostname \ + --network host \ --user $uid:$gid \ -v "$cwd":/sentry-unity \ -v $ANDROID_HOME:$ANDROID_HOME \ diff --git a/test/Scripts.Integration.Test/Scripts/CliConfiguration.cs b/test/Scripts.Integration.Test/Scripts/CliConfiguration.cs index d6ecbeaa5..6c7c46d6f 100644 --- a/test/Scripts.Integration.Test/Scripts/CliConfiguration.cs +++ b/test/Scripts.Integration.Test/Scripts/CliConfiguration.cs @@ -16,10 +16,8 @@ public override void Configure(SentryCliOptions cliOptions) cliOptions.Organization = "sentry-sdks"; cliOptions.Project = "sentry-unity-integration-tests"; - // sentry-cli derives its upload URL from the DSN whenever that DSN is not sentry.io. During - // envelope capture the DSN points at the local capture server, which would send symbol - // uploads there too. Pin the CLI to sentry.io so symbol upload keeps working either way. - cliOptions.UrlOverride = "https://sentry.io"; + // No UrlOverride on purpose: sentry-cli derives its upload URL from the DSN, so during + // envelope capture the debug files go to the capture server and end up in the artifact. Debug.Log("Sentry: CliConfiguration::Configure() finished"); } diff --git a/test/Scripts.Integration.Test/envelope-capture-server.py b/test/Scripts.Integration.Test/envelope-capture-server.py index b44b57c78..8705b48f6 100644 --- a/test/Scripts.Integration.Test/envelope-capture-server.py +++ b/test/Scripts.Integration.Test/envelope-capture-server.py @@ -20,7 +20,9 @@ import gzip import json import re +import shutil import sys +import tempfile import threading import uuid import zlib @@ -33,6 +35,8 @@ sequence = 0 label = "startup" output_dir = Path(".") +chunk_dir = Path(".") +symbol_dir = Path(".") platform_name = "unknown" @@ -64,6 +68,23 @@ def parse_envelope(data): return header, items +def parse_multipart(body, boundary): + """Yields (headers, payload) for each part of a multipart/form-data body.""" + for segment in body.split(b"--" + boundary): + if segment in (b"", b"--", b"--\r\n", b"\r\n"): + continue + segment = segment[2:] if segment.startswith(b"\r\n") else segment + head, _, payload = segment.partition(b"\r\n\r\n") + if payload.endswith(b"\r\n"): + payload = payload[:-2] + headers = {} + for line in head.decode("utf-8", "replace").splitlines(): + key, sep, value = line.partition(":") + if sep: + headers[key.strip().lower()] = value.strip() + yield headers, payload + + def decode_body(body, encoding): if not encoding: return body @@ -121,6 +142,22 @@ def do_GET(self): label = safe(new_label) print(f"--- mark: {label} ---", file=sys.stderr) self.respond(200, b'{"ok":true}') + elif url.path.endswith("/chunk-upload/"): + # sentry-cli asks what the server accepts before uploading debug files. Advertising + # uncompressed chunks keeps the upload handler trivial. + options = { + "url": f"http://{self.headers.get('Host', '127.0.0.1')}{url.path}", + "chunkSize": 8 * 1024 * 1024, + "chunksPerRequest": 64, + "maxFileSize": 2 * 1024 * 1024 * 1024, + "maxRequestSize": 32 * 1024 * 1024, + "concurrency": 1, + "hashAlgorithm": "sha1", + "compression": [], + "accept": ["debug_files", "sources", "pdbs", "portablepdbs", "il2cpp", + "bcsymbolmaps", "proguard"], + } + self.respond(200, json.dumps(options).encode()) elif url.path == "/STOP": self.respond(200, b'{"ok":true}') threading.Thread(target=self.server.shutdown).start() @@ -140,12 +177,80 @@ def read_body(self): return b"".join(chunks) return self.rfile.read(int(self.headers.get("Content-Length", 0))) + def handle_chunk_upload(self, body): + """Stores each uploaded chunk under its sha1 so assemble can stitch the file back.""" + boundary = re.search(r"boundary=([^;]+)", self.headers.get("Content-Type", "")) + if not boundary: + self.respond(400, b'{"detail":"missing boundary"}') + return + + count = 0 + for headers, payload in parse_multipart(body, boundary.group(1).strip('"').encode()): + name = re.search(r'filename="([^"]*)"', headers.get("content-disposition", "")) + if not name: + continue + (chunk_dir / name.group(1)).write_bytes(payload) + count += 1 + + print(f"stored {count} chunks", file=sys.stderr) + self.respond(200, b"{}") + + def handle_assemble(self, body): + """Reassembles uploaded chunks into the debug files sentry-cli meant to upload.""" + try: + request = json.loads(body) + except ValueError as error: + self.respond(400, json.dumps({"detail": str(error)}).encode()) + return + + response = {} + for checksum, entry in request.items(): + name = Path(entry.get("name") or checksum).name + missing = [c for c in entry.get("chunks", []) if not (chunk_dir / c).exists()] + if missing: + response[checksum] = {"state": "not_found", "missingChunks": missing, "detail": None} + continue + + # A dif and its source bundle share both debug id and name, so the checksum keeps + # them from overwriting each other. + target = symbol_dir / f"{entry.get('debug_id', 'unknown')}-{checksum[:8]}-{safe(name)}" + with target.open("wb") as out: + for chunk in entry["chunks"]: + out.write((chunk_dir / chunk).read_bytes()) + # Source bundles carry Sentry's "SYSB" magic; mark them so the corpus is self-describing. + with target.open("rb") as probe: + if probe.read(4) == b"SYSB": + target = target.rename(target.with_name(target.name + ".src")) + + # IL2CPP debug files run to gigabytes; dropping the chunks once assembled keeps peak + # disk at one copy. sentry-cli re-uploads any chunk a later probe reports missing. + for chunk in entry["chunks"]: + (chunk_dir / chunk).unlink(missing_ok=True) + print(f"assembled {target.name} ({target.stat().st_size} bytes)", file=sys.stderr) + + with state_lock: + with (symbol_dir / "index.jsonl").open("a") as index: + index.write(json.dumps({"file": target.name, "platform": platform_name, + "checksum": checksum, "size": target.stat().st_size, + "request": entry}) + "\n") + + response[checksum] = {"state": "ok", "missingChunks": [], "detail": None} + + self.respond(200, json.dumps(response).encode()) + def do_POST(self): global sequence url = urlparse(self.path) raw = self.read_body() body = decode_body(raw, self.headers.get("Content-Encoding")) + if url.path.endswith("/chunk-upload/"): + self.handle_chunk_upload(body) + return + if url.path.endswith("/assemble/"): + self.handle_assemble(body) + return + with state_lock: sequence += 1 seq, current_label = sequence, label @@ -203,7 +308,7 @@ def do_POST(self): def main(): - global output_dir, platform_name + global output_dir, chunk_dir, symbol_dir, platform_name parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--host", default="0.0.0.0") @@ -216,9 +321,16 @@ def main(): output_dir.mkdir(parents=True, exist_ok=True) platform_name = args.platform + # Debug files sentry-cli uploads land next to the envelopes; the chunks they are stitched + # from are scratch and get cleaned up on shutdown. + symbol_dir = output_dir / "debug-files" + symbol_dir.mkdir(exist_ok=True) + chunk_dir = Path(tempfile.mkdtemp(prefix="sentry-chunks-")) + server = ThreadingHTTPServer((args.host, args.port), Handler) print(f"envelope capture listening on {args.host}:{args.port} -> {output_dir}", file=sys.stderr) server.serve_forever() + shutil.rmtree(chunk_dir, ignore_errors=True) print(f"envelope capture stopped after {sequence} requests", file=sys.stderr) From 0a89476b0d872f80e9c5b8a4b5342232ef4c1424 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 17 Aug 2026 10:11:10 +0200 Subject: [PATCH 4/7] compile out dependency conflict check --- .github/workflows/ci.yml | 5 +++++ .github/workflows/test-build-android.yml | 5 +++++ .github/workflows/test-build-ios.yml | 5 +++++ .github/workflows/test-build-linux.yml | 5 +++++ .github/workflows/test-build-macos.yml | 5 +++++ .github/workflows/test-build-windows.yml | 5 +++++ 6 files changed, 30 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec69ae5ea..8633dfaac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,6 +160,11 @@ jobs: - name: Add Sentry to the project run: ./test/Scripts.Integration.Test/add-sentry.ps1 -UnityPath "$env:UNITY_PATH" -PackagePath "test-package-release" + # IntegrationTester.cs calls into the DependencyConflict package unless this define is set; + # capture runs don't need that regression check, so compile it out instead of installing it. + - name: Disable DependencyConflict + run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -Disable + - name: Configure Sentry run: ./test/Scripts.Integration.Test/configure-sentry.ps1 -UnityPath "$env:UNITY_PATH" -Platform "$env:BUILD_PLATFORM" env: diff --git a/.github/workflows/test-build-android.yml b/.github/workflows/test-build-android.yml index 510a3431f..285d456ba 100644 --- a/.github/workflows/test-build-android.yml +++ b/.github/workflows/test-build-android.yml @@ -76,6 +76,11 @@ jobs: - name: Start envelope capture server run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "android-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/android-${{ env.UNITY_VERSION }}" + # IntegrationTester.cs calls into the DependencyConflict package unless this define is set; + # capture runs don't need that regression check, so compile it out instead of installing it. + - name: Disable DependencyConflict + run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -Disable + - name: Configure Sentry run: ./test/Scripts.Integration.Test/configure-sentry.ps1 -UnityPath "$env:UNITY_PATH" -Platform "Android" env: diff --git a/.github/workflows/test-build-ios.yml b/.github/workflows/test-build-ios.yml index c30fe38c1..7fe7aa7fe 100644 --- a/.github/workflows/test-build-ios.yml +++ b/.github/workflows/test-build-ios.yml @@ -77,6 +77,11 @@ jobs: - name: Add Sentry to the project run: ./test/Scripts.Integration.Test/add-sentry.ps1 -UnityPath "$env:UNITY_PATH" -PackagePath "test-package-release" + # IntegrationTester.cs calls into the DependencyConflict package unless this define is set; + # capture runs don't need that regression check, so compile it out instead of installing it. + - name: Disable DependencyConflict + run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -Disable + - name: Configure Sentry run: ./test/Scripts.Integration.Test/configure-sentry.ps1 -UnityPath "$env:UNITY_PATH" -Platform "$env:BUILD_PLATFORM" env: diff --git a/.github/workflows/test-build-linux.yml b/.github/workflows/test-build-linux.yml index 15cd4ff8d..6b68101ee 100644 --- a/.github/workflows/test-build-linux.yml +++ b/.github/workflows/test-build-linux.yml @@ -87,6 +87,11 @@ jobs: - name: Start envelope capture server run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "linux-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/linux-${{ env.UNITY_VERSION }}" + # IntegrationTester.cs calls into the DependencyConflict package unless this define is set; + # capture runs don't need that regression check, so compile it out instead of installing it. + - name: Disable DependencyConflict + run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -Disable + - name: Configure Sentry run: ./test/Scripts.Integration.Test/configure-sentry.ps1 -UnityPath "$env:UNITY_PATH" -Platform Linux env: diff --git a/.github/workflows/test-build-macos.yml b/.github/workflows/test-build-macos.yml index 78840ce5b..78962853f 100644 --- a/.github/workflows/test-build-macos.yml +++ b/.github/workflows/test-build-macos.yml @@ -82,6 +82,11 @@ jobs: - name: Start envelope capture server run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "macos-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/macos-${{ env.UNITY_VERSION }}" + # IntegrationTester.cs calls into the DependencyConflict package unless this define is set; + # capture runs don't need that regression check, so compile it out instead of installing it. + - name: Disable DependencyConflict + run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -Disable + - name: Configure Sentry run: ./test/Scripts.Integration.Test/configure-sentry.ps1 -UnityPath "$env:UNITY_PATH" -Platform MacOS env: diff --git a/.github/workflows/test-build-windows.yml b/.github/workflows/test-build-windows.yml index cba3c0d94..2dffdacb4 100644 --- a/.github/workflows/test-build-windows.yml +++ b/.github/workflows/test-build-windows.yml @@ -82,6 +82,11 @@ jobs: - name: Start envelope capture server run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "windows-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/windows-${{ env.UNITY_VERSION }}" + # IntegrationTester.cs calls into the DependencyConflict package unless this define is set; + # capture runs don't need that regression check, so compile it out instead of installing it. + - name: Disable DependencyConflict + run: ./test/Scripts.Integration.Test/add-dependency-conflict.ps1 -Disable + - name: Configure Sentry run: ./test/Scripts.Integration.Test/configure-sentry.ps1 -UnityPath "$env:UNITY_PATH" -Platform Windows env: From 0a80a06cbb22d92320b8ef1577f4f22392ade008 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 17 Aug 2026 10:54:14 +0200 Subject: [PATCH 5/7] point sentry-cli at capture server via SENTRY_URL --- .github/workflows/test-build-android.yml | 14 ++++++++++++++ .github/workflows/test-build-linux.yml | 14 ++++++++++++++ .github/workflows/test-build-macos.yml | 14 ++++++++++++++ .github/workflows/test-build-windows.yml | 14 ++++++++++++++ .github/workflows/test-compile-ios.yml | 14 ++++++++++++++ scripts/ci-docker.sh | 1 + 6 files changed, 71 insertions(+) diff --git a/.github/workflows/test-build-android.yml b/.github/workflows/test-build-android.yml index 285d456ba..1caef190a 100644 --- a/.github/workflows/test-build-android.yml +++ b/.github/workflows/test-build-android.yml @@ -25,6 +25,9 @@ jobs: GITHUB_ACTOR: ${{ github.actor }} UNITY_PATH: docker exec unity unity-editor UNITY_VERSION: ${{ inputs.unity-version }} + # sentry-cli 3.x ignores `defaults.url` from sentry.properties, so the DSN alone does not + # redirect symbol upload. SENTRY_URL is what it honours. See docs/envelope-capture.md. + SENTRY_URL: http://127.0.0.1:8787 steps: - name: Checkout @@ -149,6 +152,17 @@ jobs: !samples/IntegrationTest/Build/*_BackUpThisFolder_ButDontShipItWithYourGame retention-days: 14 # Lower retention period - we only need this to retry CI. + # sentry-cli reporting success is not proof it reached us - it silently falls back to + # sentry.io. Fail loudly when nothing was captured. + - name: Assert debug files were captured + run: | + $dir = "test/IntegrationTest/symbols/android-${{ env.UNITY_VERSION }}/debug-files" + $files = @(Get-ChildItem $dir -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne "index.jsonl" }) + if ($files.Count -eq 0) { + throw "No debug files captured in '$dir' - sentry-cli did not upload to the capture server." + } + Write-Host "Captured $($files.Count) debug files ($([math]::Round(($files | Measure-Object Length -Sum).Sum / 1MB, 1)) MB)" + - name: Stop envelope capture server if: ${{ always() }} run: | diff --git a/.github/workflows/test-build-linux.yml b/.github/workflows/test-build-linux.yml index 6b68101ee..b492b4b79 100644 --- a/.github/workflows/test-build-linux.yml +++ b/.github/workflows/test-build-linux.yml @@ -26,6 +26,9 @@ jobs: UNITY_PATH: docker exec unity unity-editor UNITY_VERSION: ${{ inputs.unity-version }} BUILD_PLATFORM: Linux + # sentry-cli 3.x ignores `defaults.url` from sentry.properties, so the DSN alone does not + # redirect symbol upload. SENTRY_URL is what it honours. See docs/envelope-capture.md. + SENTRY_URL: http://127.0.0.1:8787 steps: - name: Checkout @@ -176,6 +179,17 @@ jobs: !samples/IntegrationTest/Build/*_BackUpThisFolder_ButDontShipItWithYourGame retention-days: 14 + # sentry-cli reporting success is not proof it reached us - it silently falls back to + # sentry.io. Fail loudly when nothing was captured. + - name: Assert debug files were captured + run: | + $dir = "test/IntegrationTest/symbols/linux-${{ env.UNITY_VERSION }}/debug-files" + $files = @(Get-ChildItem $dir -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne "index.jsonl" }) + if ($files.Count -eq 0) { + throw "No debug files captured in '$dir' - sentry-cli did not upload to the capture server." + } + Write-Host "Captured $($files.Count) debug files ($([math]::Round(($files | Measure-Object Length -Sum).Sum / 1MB, 1)) MB)" + - name: Stop envelope capture server if: ${{ always() }} run: | diff --git a/.github/workflows/test-build-macos.yml b/.github/workflows/test-build-macos.yml index 78962853f..62532e110 100644 --- a/.github/workflows/test-build-macos.yml +++ b/.github/workflows/test-build-macos.yml @@ -24,6 +24,9 @@ jobs: env: UNITY_VERSION: ${{ inputs.unity-version }} BUILD_PLATFORM: MacOS + # sentry-cli 3.x ignores `defaults.url` from sentry.properties, so the DSN alone does not + # redirect symbol upload. SENTRY_URL is what it honours. See docs/envelope-capture.md. + SENTRY_URL: http://127.0.0.1:8787 steps: - name: Checkout @@ -162,6 +165,17 @@ jobs: !samples/IntegrationTest/Build/*_BackUpThisFolder_ButDontShipItWithYourGame retention-days: 14 + # sentry-cli reporting success is not proof it reached us - it silently falls back to + # sentry.io. Fail loudly when nothing was captured. + - name: Assert debug files were captured + run: | + $dir = "test/IntegrationTest/symbols/macos-${{ env.UNITY_VERSION }}/debug-files" + $files = @(Get-ChildItem $dir -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne "index.jsonl" }) + if ($files.Count -eq 0) { + throw "No debug files captured in '$dir' - sentry-cli did not upload to the capture server." + } + Write-Host "Captured $($files.Count) debug files ($([math]::Round(($files | Measure-Object Length -Sum).Sum / 1MB, 1)) MB)" + - name: Stop envelope capture server if: ${{ always() }} run: | diff --git a/.github/workflows/test-build-windows.yml b/.github/workflows/test-build-windows.yml index 2dffdacb4..5f0d18612 100644 --- a/.github/workflows/test-build-windows.yml +++ b/.github/workflows/test-build-windows.yml @@ -24,6 +24,9 @@ jobs: env: UNITY_VERSION: ${{ inputs.unity-version }} BUILD_PLATFORM: Windows + # sentry-cli 3.x ignores `defaults.url` from sentry.properties, so the DSN alone does not + # redirect symbol upload. SENTRY_URL is what it honours. See docs/envelope-capture.md. + SENTRY_URL: http://127.0.0.1:8787 steps: - name: Checkout @@ -162,6 +165,17 @@ jobs: !samples/IntegrationTest/Build/*_BackUpThisFolder_ButDontShipItWithYourGame retention-days: 14 + # sentry-cli reporting success is not proof it reached us - it silently falls back to + # sentry.io. Fail loudly when nothing was captured. + - name: Assert debug files were captured + run: | + $dir = "test/IntegrationTest/symbols/windows-${{ env.UNITY_VERSION }}/debug-files" + $files = @(Get-ChildItem $dir -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne "index.jsonl" }) + if ($files.Count -eq 0) { + throw "No debug files captured in '$dir' - sentry-cli did not upload to the capture server." + } + Write-Host "Captured $($files.Count) debug files ($([math]::Round(($files | Measure-Object Length -Sum).Sum / 1MB, 1)) MB)" + - name: Stop envelope capture server if: ${{ always() }} run: | diff --git a/.github/workflows/test-compile-ios.yml b/.github/workflows/test-compile-ios.yml index 38d282313..e1f09e215 100644 --- a/.github/workflows/test-compile-ios.yml +++ b/.github/workflows/test-compile-ios.yml @@ -20,6 +20,9 @@ jobs: env: UNITY_VERSION: ${{ inputs.unity-version }} INIT_TYPE: ${{ inputs.init-type }} + # sentry-cli 3.x ignores `defaults.url` from sentry.properties, so the DSN alone does not + # redirect symbol upload. SENTRY_URL is what it honours. See docs/envelope-capture.md. + SENTRY_URL: http://127.0.0.1:8787 steps: - name: Checkout @@ -72,6 +75,17 @@ jobs: # Lower retention period - we only need this to retry CI. retention-days: 14 + # sentry-cli reporting success is not proof it reached us - it silently falls back to + # sentry.io. Fail loudly when nothing was captured. + - name: Assert debug files were captured + run: | + $dir = "test/IntegrationTest/symbols/ios-${{ env.UNITY_VERSION }}-${{ env.INIT_TYPE }}/debug-files" + $files = @(Get-ChildItem $dir -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ne "index.jsonl" }) + if ($files.Count -eq 0) { + throw "No debug files captured in '$dir' - sentry-cli did not upload to the capture server." + } + Write-Host "Captured $($files.Count) debug files ($([math]::Round(($files | Measure-Object Length -Sum).Sum / 1MB, 1)) MB)" + - name: Stop envelope capture server if: ${{ always() }} run: | diff --git a/scripts/ci-docker.sh b/scripts/ci-docker.sh index c3d8cc8af..79ccae4da 100755 --- a/scripts/ci-docker.sh +++ b/scripts/ci-docker.sh @@ -44,6 +44,7 @@ docker run -td --name $container \ -e UNITY_VERSION=$unityVersion \ -e GITHUB_ACTIONS="${GITHUB_ACTIONS}" \ -e SENTRY_AUTH_TOKEN="${SENTRY_AUTH_TOKEN:-}" \ + -e SENTRY_URL="${SENTRY_URL:-}" \ --workdir /sentry-unity $image # Generate unique machine-id to avoid any hardcoded values and license-fetch congestion From 50498887d2a6235508bd97f6c72deefeada03fa7 Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 17 Aug 2026 15:25:33 +0200 Subject: [PATCH 6/7] start capture server inside the build step --- .github/workflows/test-build-android.yml | 13 ++++++------- .github/workflows/test-build-linux.yml | 13 ++++++------- .github/workflows/test-build-macos.yml | 13 ++++++------- .github/workflows/test-build-windows.yml | 13 ++++++------- .github/workflows/test-compile-ios.yml | 9 +++------ .../start-capture-server.ps1 | 11 +++++++++++ 6 files changed, 38 insertions(+), 34 deletions(-) diff --git a/.github/workflows/test-build-android.yml b/.github/workflows/test-build-android.yml index 1caef190a..1e1c472f5 100644 --- a/.github/workflows/test-build-android.yml +++ b/.github/workflows/test-build-android.yml @@ -74,11 +74,6 @@ jobs: - name: Add Sentry to the project run: ./test/Scripts.Integration.Test/add-sentry.ps1 -UnityPath "$env:UNITY_PATH" -PackagePath "test-package-release" - # Envelope capture: sentry-cli takes its upload URL from the DSN, so every debug file it - # uploads during the build lands in the capture output. See docs/envelope-capture.md. - - name: Start envelope capture server - run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "android-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/android-${{ env.UNITY_VERSION }}" - # IntegrationTester.cs calls into the DependencyConflict package unless this define is set; # capture runs don't need that regression check, so compile it out instead of installing it. - name: Disable DependencyConflict @@ -90,7 +85,9 @@ jobs: SENTRY_DSN: ${{ secrets.SENTRY_TEST_DSN }} - name: Export APK - Runtime Initialization - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform "Android" -UnityVersion "$env:UNITY_VERSION" + run: | + ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "android-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/android-${{ env.UNITY_VERSION }}" + ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform "Android" -UnityVersion "$env:UNITY_VERSION" - name: Assert symbols and sources were uploaded (Runtime) run: ./test/Scripts.Integration.Test/assert-symbol-upload.ps1 -LogPath samples/IntegrationTest/Logs/sentry-symbols-upload.log @@ -119,7 +116,9 @@ jobs: Set-Content $optionsPath $content - name: Export APK - Build-Time Initialization - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform "Android" -UnityVersion "$env:UNITY_VERSION" + run: | + ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "android-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/android-${{ env.UNITY_VERSION }}" + ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform "Android" -UnityVersion "$env:UNITY_VERSION" - name: Assert symbols and sources were uploaded (Build-Time) run: ./test/Scripts.Integration.Test/assert-symbol-upload.ps1 -LogPath samples/IntegrationTest/Logs/sentry-symbols-upload.log diff --git a/.github/workflows/test-build-linux.yml b/.github/workflows/test-build-linux.yml index b492b4b79..5efef1061 100644 --- a/.github/workflows/test-build-linux.yml +++ b/.github/workflows/test-build-linux.yml @@ -85,11 +85,6 @@ jobs: - name: Add Sentry to the project run: ./test/Scripts.Integration.Test/add-sentry.ps1 -UnityPath "$env:UNITY_PATH" -PackagePath "test-package-release" - # Envelope capture: sentry-cli takes its upload URL from the DSN, so every debug file it - # uploads during the build lands in the capture output. See docs/envelope-capture.md. - - name: Start envelope capture server - run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "linux-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/linux-${{ env.UNITY_VERSION }}" - # IntegrationTester.cs calls into the DependencyConflict package unless this define is set; # capture runs don't need that regression check, so compile it out instead of installing it. - name: Disable DependencyConflict @@ -101,7 +96,9 @@ jobs: SENTRY_DSN: ${{ secrets.SENTRY_TEST_DSN }} - name: Build with Sentry SDK (Breakpad backend) - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform Linux -UnityVersion "$env:UNITY_VERSION" + run: | + ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "linux-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/linux-${{ env.UNITY_VERSION }}" + ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform Linux -UnityVersion "$env:UNITY_VERSION" - name: Assert symbols and sources were uploaded (Breakpad backend) run: ./test/Scripts.Integration.Test/assert-symbol-upload.ps1 -LogPath unity.log @@ -128,7 +125,9 @@ jobs: Set-Content $optionsPath $content - name: Build with Sentry SDK (Native backend) - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform Linux -UnityVersion "$env:UNITY_VERSION" + run: | + ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "linux-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/linux-${{ env.UNITY_VERSION }}" + ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform Linux -UnityVersion "$env:UNITY_VERSION" - name: Assert symbols and sources were uploaded (Native backend) run: ./test/Scripts.Integration.Test/assert-symbol-upload.ps1 -LogPath unity.log diff --git a/.github/workflows/test-build-macos.yml b/.github/workflows/test-build-macos.yml index 62532e110..01b832fc7 100644 --- a/.github/workflows/test-build-macos.yml +++ b/.github/workflows/test-build-macos.yml @@ -80,11 +80,6 @@ jobs: - name: Add Sentry to the project run: ./test/Scripts.Integration.Test/add-sentry.ps1 -UnityPath "$env:UNITY_PATH" -PackagePath "test-package-release" - # Envelope capture: sentry-cli takes its upload URL from the DSN, so every debug file it - # uploads during the build lands in the capture output. See docs/envelope-capture.md. - - name: Start envelope capture server - run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "macos-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/macos-${{ env.UNITY_VERSION }}" - # IntegrationTester.cs calls into the DependencyConflict package unless this define is set; # capture runs don't need that regression check, so compile it out instead of installing it. - name: Disable DependencyConflict @@ -96,7 +91,9 @@ jobs: SENTRY_DSN: ${{ secrets.SENTRY_TEST_DSN }} - name: Build with Sentry SDK (Cocoa backend) - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform MacOS -UnityVersion "$env:UNITY_VERSION" + run: | + ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "macos-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/macos-${{ env.UNITY_VERSION }}" + ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform MacOS -UnityVersion "$env:UNITY_VERSION" env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -125,7 +122,9 @@ jobs: Set-Content $optionsPath $content - name: Build with Sentry SDK (Native backend) - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform MacOS -UnityVersion "$env:UNITY_VERSION" + run: | + ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "macos-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/macos-${{ env.UNITY_VERSION }}" + ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform MacOS -UnityVersion "$env:UNITY_VERSION" env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/.github/workflows/test-build-windows.yml b/.github/workflows/test-build-windows.yml index 5f0d18612..cabccb30c 100644 --- a/.github/workflows/test-build-windows.yml +++ b/.github/workflows/test-build-windows.yml @@ -80,11 +80,6 @@ jobs: - name: Add Sentry to the project run: ./test/Scripts.Integration.Test/add-sentry.ps1 -UnityPath "$env:UNITY_PATH" -PackagePath "test-package-release" - # Envelope capture: sentry-cli takes its upload URL from the DSN, so every debug file it - # uploads during the build lands in the capture output. See docs/envelope-capture.md. - - name: Start envelope capture server - run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "windows-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/windows-${{ env.UNITY_VERSION }}" - # IntegrationTester.cs calls into the DependencyConflict package unless this define is set; # capture runs don't need that regression check, so compile it out instead of installing it. - name: Disable DependencyConflict @@ -96,7 +91,9 @@ jobs: SENTRY_DSN: ${{ secrets.SENTRY_TEST_DSN }} - name: Build with Sentry SDK (Crashpad backend) - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform Windows -UnityVersion "$env:UNITY_VERSION" + run: | + ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "windows-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/windows-${{ env.UNITY_VERSION }}" + ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform Windows -UnityVersion "$env:UNITY_VERSION" env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} @@ -125,7 +122,9 @@ jobs: Set-Content $optionsPath $content - name: Build with Sentry SDK (Native backend) - run: ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform Windows -UnityVersion "$env:UNITY_VERSION" + run: | + ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "windows-${{ env.UNITY_VERSION }}" -Output "test/IntegrationTest/symbols/windows-${{ env.UNITY_VERSION }}" + ./test/Scripts.Integration.Test/build-project.ps1 -UnityPath "$env:UNITY_PATH" -Platform Windows -UnityVersion "$env:UNITY_VERSION" env: SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} diff --git a/.github/workflows/test-compile-ios.yml b/.github/workflows/test-compile-ios.yml index e1f09e215..fe33d4a1b 100644 --- a/.github/workflows/test-compile-ios.yml +++ b/.github/workflows/test-compile-ios.yml @@ -36,13 +36,10 @@ jobs: - name: Extract app project run: tar -xvzf "test-app-$env:INIT_TYPE.tar.gz" - # Envelope capture: sentry-cli takes its upload URL from the DSN, so every debug file it - # uploads during the build lands in the capture output. See docs/envelope-capture.md. - - name: Start envelope capture server - run: ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "ios-${{ env.UNITY_VERSION }}-${{ env.INIT_TYPE }}" -Output "test/IntegrationTest/symbols/ios-${{ env.UNITY_VERSION }}-${{ env.INIT_TYPE }}" - - name: iOS test - run: ./scripts/compile-xcode-project.ps1 -iOSMinVersion "17.0" + run: | + ./test/Scripts.Integration.Test/start-capture-server.ps1 -Platform "ios-${{ env.UNITY_VERSION }}-${{ env.INIT_TYPE }}" -Output "test/IntegrationTest/symbols/ios-${{ env.UNITY_VERSION }}-${{ env.INIT_TYPE }}" + ./scripts/compile-xcode-project.ps1 -iOSMinVersion "17.0" timeout-minutes: 20 - name: Assert symbols and sources were uploaded diff --git a/test/Scripts.Integration.Test/start-capture-server.ps1 b/test/Scripts.Integration.Test/start-capture-server.ps1 index 53ceb3e6a..53bbc0d63 100644 --- a/test/Scripts.Integration.Test/start-capture-server.ps1 +++ b/test/Scripts.Integration.Test/start-capture-server.ps1 @@ -17,6 +17,17 @@ if ([string]::IsNullOrEmpty($Output)) { $Output = "test/IntegrationTest/envelopes/$Platform" } +# Build jobs call this from every build step, because a detached server does not reliably survive +# the gap between steps. Reuse the running one instead of fighting over the port and the log file. +try { + Invoke-WebRequest -Uri "http://127.0.0.1:$Port/HEALTH" -TimeoutSec 2 -UseBasicParsing | Out-Null + Write-Host "Envelope capture server already running on port $Port" + exit 0 +} +catch { + # nothing listening yet - start one below +} + $python = if (Get-Command python3 -ErrorAction SilentlyContinue) { "python3" } else { "python" } $server = Join-Path $PSScriptRoot "envelope-capture-server.py" From c6ecc98ed8bb7c08fc6efc77da4ab923f59bb1dc Mon Sep 17 00:00:00 2001 From: bitsandfoxes Date: Mon, 17 Aug 2026 16:03:48 +0200 Subject: [PATCH 7/7] keep assembled difs ok on re-poll and clear stale port --- .../envelope-capture-server.py | 11 +++++++++++ .../start-capture-server.ps1 | 15 ++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/test/Scripts.Integration.Test/envelope-capture-server.py b/test/Scripts.Integration.Test/envelope-capture-server.py index 8705b48f6..b51d83365 100644 --- a/test/Scripts.Integration.Test/envelope-capture-server.py +++ b/test/Scripts.Integration.Test/envelope-capture-server.py @@ -38,6 +38,7 @@ chunk_dir = Path(".") symbol_dir = Path(".") platform_name = "unknown" +assembled = set() def parse_envelope(data): @@ -206,6 +207,15 @@ def handle_assemble(self, body): response = {} for checksum, entry in request.items(): name = Path(entry.get("name") or checksum).name + + # sentry-cli polls assemble until every file reports `ok`. Once assembled we drop the + # chunks, so answer from this set rather than re-checking them - otherwise the next + # poll reports the file as missing and sentry-cli fails the upload. + with state_lock: + if checksum in assembled: + response[checksum] = {"state": "ok", "missingChunks": [], "detail": None} + continue + missing = [c for c in entry.get("chunks", []) if not (chunk_dir / c).exists()] if missing: response[checksum] = {"state": "not_found", "missingChunks": missing, "detail": None} @@ -229,6 +239,7 @@ def handle_assemble(self, body): print(f"assembled {target.name} ({target.stat().st_size} bytes)", file=sys.stderr) with state_lock: + assembled.add(checksum) with (symbol_dir / "index.jsonl").open("a") as index: index.write(json.dumps({"file": target.name, "platform": platform_name, "checksum": checksum, "size": target.stat().st_size, diff --git a/test/Scripts.Integration.Test/start-capture-server.ps1 b/test/Scripts.Integration.Test/start-capture-server.ps1 index 53bbc0d63..ae0ac95ba 100644 --- a/test/Scripts.Integration.Test/start-capture-server.ps1 +++ b/test/Scripts.Integration.Test/start-capture-server.ps1 @@ -25,7 +25,20 @@ try { exit 0 } catch { - # nothing listening yet - start one below + # Nothing answered. A server from a previous step may still be holding the port without + # serving (the runner suspends leftovers between steps), which would make the new one fail + # with "Address already in use" - so clear the port before starting. + if ($IsWindows) { + Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | + ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue } + } + else { + $stale = & lsof -ti "tcp:$Port" 2>$null + foreach ($processId in $stale) { + Write-Host "Killing stale listener on port $Port (pid $processId)" + & kill -9 $processId 2>$null + } + } } $python = if (Get-Command python3 -ErrorAction SilentlyContinue) { "python3" } else { "python" }