ostrace
Stream, inspect and export iOS device logs — on Windows, macOS and Linux.
ostrace reads Apple's unified log over com.apple.os_trace_relay, the same
service Console.app uses. That means structured records: subsystem, category,
thread id and the emitting library, at DEBUG level and above — not just the
NOTICE-tier text that the legacy syslog_relay path returns. Reading both
services from one iPhone18,2 over the same minute, os_trace_relay delivered
233,956 records and syslog_relay 11,642: 222,477 of them — 95% — reach one
service and not the other.
Why
Both relays were read at the same time, from one process, over the same minute — the same device delivers 5,140 records in one 20-second window and 36,763 in another, so measuring them one after the other compares the device's mood rather than the two services.
What syslog_relay returns is the NOTICE tier and above, and nothing below it.
Across three runs it matched that boundary to within 1.5%, and once exactly.
Tools built on it — idevicesyslog among them — therefore never receive the
DEBUG and INFO records, which are the ones that matter when you are debugging
your own app. Roughly 90% of the log is on the other side of that line.
The ratio itself is not the claim: it moved between 9.1× and 20.1× across those runs, with how much DEBUG the device happened to be emitting. What did not move is which tiers arrive.
This compares two services, not two applications. Console.app, Xcode's
device console and pymobiledevice3 syslog live all read os_trace_relay, the
same one ostrace does, and they see the same records — the difference there is
the tool, not the data. The comparison above is against the text path, which
is what idevicesyslog and the single-file predecessor this project replaces
were built on.
Separately, 96.8% of records in the reference capture carry a subsystem and
category that the text-based pipeline discards — measured between 80% and 97%
across the captures in tests/fixtures/ and since.
os_trace_relay is still an ordinary lockdown service on iOS 26: no RemoteXPC
tunnel, no administrator privileges.
Install
pipx install "ostrace[gui]"
pipx (or uv tool install) is the recommended route. Files installed by a
package manager are never quarantined by macOS, so Gatekeeper never enters the
picture. Leave off [gui] for the command line alone, which is a much smaller
install.
| Python | 3.11 or newer |
| Windows | Apple Mobile Device Service must be installed. It ships with iTunes from apple.com — not the Microsoft Store build. See docs/troubleshooting.md. |
| macOS | Nothing beyond the pip install; usbmuxd is already present. The graphical viewer needs macOS 13 or newer, which is Qt 6.11's floor; the command line does not. |
| Linux | Nothing beyond the pip install, plus a running usbmuxd. |
| Device | Connected over USB and paired (tap Trust on the device). |
A note on install size: ostrace depends on pymobiledevice3, which pulls in
90 distributions of its own — measured, not estimated — and the GUI extra brings
PySide6. The full install is in the hundreds of megabytes. This is documented
rather than hidden; see
docs/adr/0002.
The viewer
ostrace-gui
Capture streams from an attached device; Open reads a capture from disk. Either way the records go into a virtualised table coloured by severity, with every field of the selected row in the pane below — including the two that routinely get confused, the process executable and the library that emitted the line.
A few things it does deliberately:
- Pause freezes the view and nothing else. The capture keeps running and keeps writing every record to the session file, so nothing is lost by looking away. Disconnect is the control that releases the device, and it is named after its consequence rather than called "stop".
- A gap is a row. When the device disconnects mid-capture, the hole in the log appears in the table where it happened, in position, and it survives every filter. A filter says which records you want; a gap says whether the answer is complete, and hiding one to satisfy the other would make the view lie about the capture.
- The strip beside the table marks every error, gap and mark across the whole capture, not just the visible part. Clicking jumps there. It is the only thing that will tell you about a discontinuity forty thousand rows above where you are reading.
- Filters keep your place. Changing a filter anchors the selection and the viewport to the record you were reading, not to a row number, and falls back to the nearest survivor when that record is filtered away.
| Ctrl+R | Capture from the device |
| Ctrl+P | Pause the view |
| Ctrl+D | Disconnect, releasing the device |
| Ctrl+O | Open a capture |
| Ctrl+E | Export |
| Ctrl+F or / | Find |
| E / Shift+E | Next / previous error |
| ] / [ | Next / previous gap |
| M | Mark the row |
| F1 | Every binding, generated from the same table the menus use |
The command line
ostrace doctor # why can't I see my device?
ostrace devices --verbose # list what is attached
ostrace capture --duration 60 # stream to a session file
ostrace export CAPTURE # turn it into something readable
ostrace capture writes a session directory under your data directory and
prints the path. --max-records and --duration both stop it; so does Ctrl-C,
cleanly. If the device disconnects mid-capture it reconnects and records a gap
rather than pretending the log is continuous.
Start with doctor if anything is not working. Almost every problem here is
environmental rather than a bug, and it checks the causes in the order they
actually occur:
[ ok ] ostrace 0.2.0 on Python 3.13.14 (win32)
[ ok ] usbmux Apple Mobile Device Service on 127.0.0.1:27015
[FAIL] devices none connected
Connect the device over USB and unlock it. A charge-only cable
gives exactly this symptom.
What a script can rely on
| Exit code | |
|---|---|
0 |
It worked. |
1 |
It did not — no device, an unreadable capture, a path the filesystem refused. The message names the cause and the hint names the remedy. |
2 |
The command line itself was wrong, and nothing ran. |
130 |
Ctrl-C, which is what a shell reports for it. A capture still finalises its session file and releases the device on the way out. |
devices and doctor exit 1 when they find nothing. "No devices
connected" is both the answer and a failure, and a script waiting for a phone to
appear should not have to read English to notice that it has not.
--quiet means a different thing on each command that has one, because they are
answering different questions:
ostrace capture --quietdrops the progress counter, which was on stderr. The record count and the path still go to stdout — those are the result, not progress.ostrace export --quietprints only the destination. The notes saying what the export left out stay on stderr regardless: they are the bad news, and a flag about stdout is not permission to hide it.
Both commands leave the path they wrote as the last line of stdout.
Exporting
ostrace export needs no device, and the viewer's export dialog offers the
same formats. It reads a session directory or a bare capture file and writes
beside it, named after it.
--format |
What it is for |
|---|---|
agent-bundle (default) |
A directory of eight tab-separated text files to investigate with grep and bounded line reads. The only format that loses nothing — see docs/formats/agent-bundle.md. |
text |
Aligned columns, one record per line, for reading in a terminal. |
markdown |
A document with a summary and the records verbatim, to paste into an issue. |
jsonl |
One JSON object per record — the session format without the gzip. |
ai-report |
A summary that shrinks to a token budget, for handing to a model. |
trace |
Verbatim windows around each error, for following what led to one. |
ostrace export capture.ostrace # a bundle beside it
ostrace export capture.ostrace -f trace # what led to each error
ostrace export capture.ostrace -f ai-report --budget-tokens 20000
Everything except the bundle is a summary, and each one states what it left out — the gaps in the capture, the patterns that did not fit, the anchors it could not reach. An export that quietly stops reads as complete, and a reader then draws conclusions from an absence that is an artefact of the export rather than a fact about the device.
Using it as a library
Everything the command line does is importable, and reading a capture costs
nothing that talking to a device would cost. No import listed below loads Qt or
pymobiledevice3 — 90 distributions, measured — including the one that speaks
to a phone: the device library is reached from inside the call that opens the
relay, so it arrives when you connect rather than when you import. That is also
why there is deliberately no flat re-export at the top of the package. One line
there would put the whole device stack behind every offline use.
from ostrace.storage import open_capture
capture = open_capture("2026-08-12T13-04-19")
for item in capture: # records and gaps, in the order they arrived
...
| Import | What it is for |
|---|---|
from ostrace.model import Record, Gap, Level, DeviceInfo, Platform |
The vocabulary. Everything downstream of a source speaks it and nothing else. |
from ostrace.storage import open_capture, Capture |
Read a session directory or a bare capture file without having to know which one you were handed. |
from ostrace.sources import ReplaySource, LogSource |
A recorded session as a stream. LogSource is the protocol a live device satisfies too, which is what makes the two substitutable. |
from ostrace.capture import capture, CaptureResult |
Run a capture: async, takes any LogSource, writes a session file. |
from ostrace.exporters import EXPORTERS / from ostrace.exporters.base import register |
The six formats by name, and how to add a seventh. |
from ostrace.errors import OstraceError |
The base of everything raised deliberately. Every subclass carries a hint, and says whether retrying could work. |
from ostrace.sources.os_trace import OsTraceSource |
A live device, over USB. The one that eventually needs pymobiledevice3, and the only one that needs a phone. |
Below 1.0.0 these names are not frozen, but they are the supported surface: if
one moves, the CHANGELOG
says so. Anything not listed here is internal, ostrace.gui included.
What this is not
-
There are no downloadable binaries. Install from PyPI; that is also what keeps macOS from quarantining anything.
-
Nothing is code-signed. It does not need to be on the install route above.
-
macOS has now been run by hand, once. Every test including the eleven that need a real iPhone passes on macOS 26.3.1, and the assumptions that were marked unverified in the source have been checked. That is one pass on one machine, not a support commitment: that machine drove a single non-Retina display, so nothing here has yet been seen at the 2× device pixel ratio most Macs run at. Reports are still welcome.
The same capture on macOS. The window carries no menu bar of its own: there it belongs to the screen.
This pair is from 0.1.2 and the Windows pair above is from 0.2.0, so the filter bar differs between them: 0.2.0 moved the
Regexcheckbox inside the Search field and put a≠in Process and Subsystem. A macOS screenshot has to be rendered on a Mac under thecocoaplugin — the offscreen plugin resolves the interface font to Qt's genericSans Serif, which is right about layout and wrong about the one thing anybody looks at a macOS screenshot for — so this one waits for a Mac rather than being regenerated in CI. -
Only iOS is supported. The device layer is written around lockdown and
os_trace_relay.
Documentation
| docs/adr/ | Architecture decision records: what was decided and why |
| docs/design/gui.md | The viewer's behaviour contract, written before the code |
| docs/research/ | The measurements and comparisons the decisions rest on |
| docs/formats/ | On-disk format contracts |
| docs/troubleshooting.md | When no device shows up |
| CONTRIBUTING.md | Development setup |
Licence
GPL-3.0-or-later. See LICENSE.
ostrace imports pymobiledevice3, which is GPL-3.0-or-later; under the
standard FSF reading that makes a combined work, so matching the licence is the
clean answer rather than a reluctant one. Reasoning in
docs/adr/0003.
The application mark — src/ostrace/gui/icons/app.svg and the card built from
it — is under the same licence as everything else here, so nothing stops you
redistributing it. The request is only that a fork which changes behaviour draws
its own, because an icon in a taskbar is how somebody tells one program from
another and there is no way to check which one they got.
Acknowledgements
ostrace is a viewer on top of pymobiledevice3
by doronz88, which does the genuinely hard part: speaking Apple's lockdown and
usbmux protocols in pure Python.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file ostrace-0.2.0.tar.gz.
File metadata
- Download URL: ostrace-0.2.0.tar.gz
- Upload date:
- Size: 1.8 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c004d61087dd9316873eb4c846b6f885fd38f43f8ec8700b734bf39604820c59
|
|
| MD5 |
48cd8ae8cc71667154b48321afb4c42a
|
|
| BLAKE2b-256 |
b4e0bab163c1773fc529d7088b42ee53a4fd96cc776fa35f154ebe50cd515b6d
|
Provenance
The following attestation bundles were made for ostrace-0.2.0.tar.gz:
Publisher:
release.yml on BerkayCaglar/ostrace
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ostrace-0.2.0.tar.gz -
Subject digest:
c004d61087dd9316873eb4c846b6f885fd38f43f8ec8700b734bf39604820c59 - Sigstore transparency entry: 2469043825
- Sigstore integration time:
-
Permalink:
BerkayCaglar/ostrace@4b417e5ca9348c6d34f8baaa810bab3556c0b398 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/BerkayCaglar
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@4b417e5ca9348c6d34f8baaa810bab3556c0b398 -
Trigger Event:
push
-
Statement type:
File details
Details for the file ostrace-0.2.0-py3-none-any.whl.
File metadata
- Download URL: ostrace-0.2.0-py3-none-any.whl
- Upload date:
- Size: 279.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
34acc3a6f8df42d4619644ac90e9e81d99f12a8e8c8e277df4b36e457404fcdf
|
|
| MD5 |
fcb2d5abfa57d10316d8d7b16489dc9c
|
|
| BLAKE2b-256 |
fe2c8993eb9d3f931bc40d0bb3d0059ebd0baa72964fb505f6e47fe45be8d425
|
Provenance
The following attestation bundles were made for ostrace-0.2.0-py3-none-any.whl:
Publisher:
release.yml on BerkayCaglar/ostrace
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ostrace-0.2.0-py3-none-any.whl -
Subject digest:
34acc3a6f8df42d4619644ac90e9e81d99f12a8e8c8e277df4b36e457404fcdf - Sigstore transparency entry: 2469043890
- Sigstore integration time:
-
Permalink:
BerkayCaglar/ostrace@4b417e5ca9348c6d34f8baaa810bab3556c0b398 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/BerkayCaglar
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@4b417e5ca9348c6d34f8baaa810bab3556c0b398 -
Trigger Event:
push
-
Statement type: