Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Collecting Safari Crash Logs as Test Evidence

Rent a hosted Mac from Figment and wire the shell post-step below into your pipeline so every Safari crash during a test run gets packaged as a dated .tar.gz CI artefact before the next job sweeps ~/Library/Logs/DiagnosticReports clean. For current Safari on current macOS an M-series plan from $95/month (Figment Mac hosting) is the recommended starting point; the $29 virtual Intel macOS instance also writes DiagnosticReports and runs the same script when you only need to exercise the collection path. The script timestamps the test window, picks up only new Safari .ips files, extracts the crashing thread, and tarballs the bundle as a named CI artefact.

Where macOS Stores Safari Crash Reports

macOS writes application crash reports to two locations, documented by Apple in Diagnosing issues using crash reports and device logs:

  • ~/Library/Logs/DiagnosticReports/ — per-user crash reports; writable by the current user process and the useful one for CI scripts.
  • /Library/Logs/DiagnosticReports/ — system-wide reports; needs elevated privileges and is rarely required for Safari automation.

Since macOS 12 (Monterey) crash reports use the .ips extension and store their payload as two-part JSON: the first line is a compact metadata header containing bug_type, os_version, timestamp and incident_identifier; the remaining lines form the larger JSON body with thread stacks, binary images and exception details. Older .crash files (plain text) can still appear from older tooling, so your collection script should handle both extensions.

For Safari specifically, crash files are named with a Safari- prefix followed by a timestamp, e.g. Safari-2026-11-10-141523.ips.

Parsing the Crash Header Fields

The header line of an .ips file is a single JSON object you can read with python3 or jq. Fields worth recording as evidence:

FieldMeaning
os_versionmacOS version at time of crash, e.g. "macOS 15.5 (24F74)"
timestampLocal crash time with timezone offset
bug_type309 for a standard application crash
incident_identifierUnique UUID for this crash report
nameProcess name, e.g. "Safari"

Extract them with a small helper:

# header_fields.sh
IPS_FILE="$1"
python3 - "$IPS_FILE" <<'EOF'
import sys, json
with open(sys.argv[1]) as f:
    header = json.loads(f.readline())
print(json.dumps({
    "incident":   header.get("incident_identifier"),
    "timestamp":  header.get("timestamp"),
    "os_version": header.get("os_version"),
    "bug_type":   header.get("bug_type"),
    "name":       header.get("name"),
}, indent=2))
EOF

This is an unexecuted example — point it at a real .ips file in your own DiagnosticReports directory before relying on the field names.

Filtering to the Test Execution Window

You only want crash files that appeared during your test run. Record a marker epoch with date +%s before the first test invocation, then compare each file's modification time:

TEST_START=$(date +%s)
# ... run xcodebuild test / pytest / WebDriver ...
find ~/Library/Logs/DiagnosticReports \
  \( -name 'Safari-*.ips' -o -name 'Safari-*.crash' \) -print |
while read -r f; do
  MTIME=$(stat -f '%m' "$f")
  if [ "$MTIME" -ge "$TEST_START" ]; then
    echo "$f"
  fi
done

stat -f '%m' is the BSD form shipped on macOS and returns the modification time as a Unix epoch integer — directly comparable with the recorded TEST_START.

The Collection Script: Archive as a Dated CI Artefact

Save the script below as scripts/collect_safari_crashes.sh. It ties together timestamp gating, header extraction, crashing-thread extraction for legacy .crash files, and final tarball packaging.

#!/usr/bin/env bash
# collect_safari_crashes.sh
# Usage: collect_safari_crashes.sh <TEST_START_EPOCH> <OUTPUT_DIR>

set -euo pipefail

TEST_START="${1:?Provide TEST_START epoch as first argument}"
OUTPUT_DIR="${2:?Provide OUTPUT_DIR as second argument}"
DIAG_DIR="$HOME/Library/Logs/DiagnosticReports"
BUNDLE_NAME="safari-crashes-$(date -u +%Y%m%dT%H%M%SZ)"
STAGE_DIR="$(mktemp -d)"
trap 'rm -rf "$STAGE_DIR"' EXIT

found=0
while IFS= read -r f; do
  MTIME=$(stat -f '%m' "$f")
  if [ "$MTIME" -ge "$TEST_START" ]; then
    cp "$f" "$STAGE_DIR/"
    found=$((found + 1))
    echo "  Collected: $(basename "$f")"

    if [[ "$f" == *.crash ]]; then
      awk '/^Thread [0-9]+ Crashed/,/^$/' "$f" \
        > "$STAGE_DIR/$(basename "${f%.crash}").crashing-thread.txt" || true
    fi

    if [[ "$f" == *.ips ]]; then
      python3 - "$f" > "$STAGE_DIR/$(basename "${f%.ips}").header.json" <<'PYEOF' || true
import sys, json
with open(sys.argv[1]) as fh:
    try:
        header = json.loads(fh.readline())
    except json.JSONDecodeError:
        header = {}
print(json.dumps({
    "incident":   header.get("incident_identifier", "unknown"),
    "timestamp":  header.get("timestamp", "unknown"),
    "os_version": header.get("os_version", "unknown"),
    "bug_type":   header.get("bug_type", "unknown"),
}, indent=2))
PYEOF
    fi
  fi
done < <(find "$DIAG_DIR" \( -name 'Safari-*.ips' -o -name 'Safari-*.crash' \) 2>/dev/null)

if [ "$found" -gt 0 ]; then
  mkdir -p "$OUTPUT_DIR"
  ARCHIVE="$OUTPUT_DIR/${BUNDLE_NAME}.tar.gz"
  tar -czf "$ARCHIVE" -C "$STAGE_DIR" .
  echo "Archived $found crash file(s) to $ARCHIVE"
else
  echo "No new Safari crash files found."
fi

Design notes:

  • set -euo pipefail plus the EXIT trap keeps the staging directory from leaking.
  • || true around the per-file extractors prevents one malformed crash blocking the archive of valid ones.
  • Crashing-thread awk extraction only runs on plain-text .crash files; .ips files are JSON and get the structured header dump instead.
  • The script exits non-zero only on the argument check — it will not fail your CI job just because no crashes occurred.

Uploading the Bundle in GitHub Actions and GitLab CI

GitHub Actions:

- name: Record test start
  id: ts
  run: echo "epoch=$(date +%s)" >> "$GITHUB_OUTPUT"

- name: Run Safari tests
  run: xcodebuild test -scheme "$XCODE_SCHEME" -destination 'platform=macOS'

- name: Collect Safari crash logs
  if: always()
  run: bash scripts/collect_safari_crashes.sh "${{ steps.ts.outputs.epoch }}" crash-artefacts

- name: Upload crash bundle
  if: always()
  uses: actions/upload-artifact@v4
  with:
    name: safari-crashes-${{ github.sha }}
    path: crash-artefacts/
    retention-days: 14

Pass your project's real scheme in XCODE_SCHEME; there is no default scheme name here and inventing one will just make xcodebuild fail silently inside the matrix.

GitLab CI:

GitLab's after_script runs in a fresh shell, so variables set in script do not survive. Persist the epoch to a file:

test:safari:
  script:
    - date +%s > .test_start_epoch
    - xcodebuild test -scheme "$XCODE_SCHEME" -destination 'platform=macOS'
  after_script:
    - bash scripts/collect_safari_crashes.sh "$(cat .test_start_epoch)" crash-artefacts
  artifacts:
    name: "safari-crashes-${CI_COMMIT_SHA}"
    paths:
      - crash-artefacts/
    expire_in: 14 days
    when: always

The when: always / if: always() guards are the point: a Safari crash exits the test step non-zero, and without those guards the upload step is skipped — exactly when you need the evidence.

Correlating Crash Timestamps with XCTest Test Cases

The .ips header timestamp field pins the crash to the second. To correlate it with a specific test case, record each test's start time in your test body and pull it out of the .xcresult afterwards:

func testSafariRendersCheckout() throws {
    let caseStart = ISO8601DateFormatter().string(from: Date())
    XCTContext.runActivity(named: "test-window-\(caseStart)") { _ in }
    // ... run your test ...
}

After the run, parse .xcresult with xcrun xcresulttool and align the recorded test-window-... activities with the timestamp from each collected crash header. One crash file falling inside one test window is the strongest signal you get before attaching a debugger.

Choosing the Right Figment Host for This Job

The script above targets DiagnosticReports, which records crashes from macOS-native processes on the host. The iOS Simulator keeps its own path at ~/Library/Logs/CoreSimulator/ and only captures Simulator-process crashes, not Safari for macOS. So you need an actual macOS host — any of these works, with different trade-offs:

  • $29 virtual Intel macOS — 2 vCPU, 8 GiB RAM, 80 GiB disk; runs macOS, writes DiagnosticReports, drives Safari for scripted collection. No GPU acceleration, no audio, Intel architecture only. VM capacity is being verified; ask for current availability. Access is via the browser console, and outbound CI agents can call back to GitHub/GitLab.
  • M-series Mac from $95/month — recommended when you need current Safari builds, Apple-silicon-only tooling, Xcode 27, or realistic rendering/GPU behaviour. Exact chip, RAM and disk are confirmed in the quote. Physical plans collect requests until supply is confirmed.
  • Managed Build Mac at $249/month — one M-series Mac with CI setup, backups, monitoring and 30 minutes of monthly support. The right pick if you want the crash-collection step above installed and watched rather than maintained in-house.

For context when comparing: CloudMac publishes a Mac mini M4 (16 GB / 256 GB) at $99.99/month, and their homepage lists a $999/year prepay on the same configuration — roughly $83.25 effective monthly, below 12×$95 at Figment's entry price. Figment's $95 is an entry price for an unspecified M-series configuration; if you need an exact M4/16 GB match, request a quote. Figment's difference on the managed tier is the specific CI setup, backups, monitoring and included support minutes bundled into one line item — ask for those explicitly if that is the work you need done.

Browse plans and request a configuration at Figment Mac hosting → get started.


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms