A terminal client for Laravel Telescope that tails several apps at once.
Telescope already separates collection (watchers) from storage (telescope_entries)
from presentation (a Vue SPA). This replaces only the third layer. Nothing is
installed into your apps — they keep using stock Telescope, and this reads the
tables they already write.
TeleTui · terminal request inspector [ app: all ▾ ] ⠋ watching entries… [/ Search Tag ]
╭──────────────╮ ╭────────────────────────────────────────────────────────────────────────╮
│ E N T R I E S│ │ Requests 3 entries │
│❯ Requests │ │────────────────────────────────────────────────────────────────────────│
│ Commands │ │ VERB APP PATH STATUS DURATION HAPPENED │
│ Schedule │ │────────────────────────────────────────────────────────────────────────│
│ Jobs │ │▌❯ GET storefront /admin/users?page=2 200 1284ms 8h ago →│
│ │ │ POST storefront /api/v1/sessions 422 96ms 8h ago →│
│ Batches │ │ GET billing /invoices/2027 500 501ms 1d ago →│
│ … │ │────────────────────────────────────────────────────────────────────────│
│ Views │ │ ● ○ ○ page 1/3 · 3 shown j/k move · enter inspect │
╰──────────────╯ ╰────────────────────────────────────────────────────────────────────────╯
NORMAL teletui://all/requests ⠋ live
↑/k up ↓/j down enter details esc back / filter ⇥ tabs [ ] section a app p pause q quit
The UI is fully mouse-aware: click sidebar sections, table rows, tabs, the app
selector (a dropdown), the ← esc back button, the live/paused segment, and
{/[ lines in the response tree to fold them; the wheel scrolls the table
and the detail stack.
- List — one sidebar section per Telescope entry type. Requests get
VERB APP PATH STATUS DURATION HAPPENEDcolumns; every other section adapts toTIME APP SUMMARY META. Newest entries are on top; while follow is on the cursor pins to the newest row. - Request detail (
enteron a request) — a scrollable stack of four panels: the field grid with a color-graded duration bar, Payload/Headers, Response/Headers/Session with a foldable JSON tree, and Related entries, auto-correlated from the request'sbatch_idand grouped per type into tabs. - Entry detail (
enteron a non-request row, or click a related row) — typed fields plus the SQL/log/dump content, andescreturns to the request it belongs to.
With Go 1.25+ installed:
go install github.com/pochocho/teletui@latestThe binary lands in $GOBIN (default ~/go/bin — make sure it is on your
PATH). Then:
teletui config # creates the config file if missing and opens it in $EDITOR
teletui # start the TUI
teletui -version # print the installed versionteletui config writes a commented starter template to the platform default
path (see the table below) — uncomment an example app, point the DSN at a
database with telescope_entries, and run teletui. Pass -config <path> to
either command to use a different location.
Everything below assumes you have just cloned the repo and have nothing else set up. The fastest path to a running TUI needs no database at all — step 3 generates a fixture — so do that first and add your real app afterwards.
Go 1.25.0 or newer. The Charm v2 libraries require it, and the build fails with an unhelpful message on older toolchains.
brew install go # macOS
go version # must print go1.25.0 or highergo mod download # or `go mod tidy` if you have edited imports
go build ./... # compile every package
go vet ./... # should be silent
go build -o teletui .
./teletui --help # prints -config and its default pathEvery driver is pure Go — modernc.org/sqlite needs no cgo — so this
cross-compiles to a static binary you can drop on a jump box:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o telescope-linux .This writes a sqlite file with Telescope's exact schema and ~70 synthetic entries covering every entry type, plus the awkward cases: a 40-query N+1 batch, a failed job with its exception, malformed JSON content, a NULL timestamp, and duplicate exceptions that Telescope would hide.
go run ./testdata/seed.go -out /tmp/t.sqliteIt prints the assertions it has set up, including the exact local time a known
row must render as. Add -prune to punch a gap in the sequence range, which is
how the pruned-table backfill path gets exercised.
Confirm Telescope is actually writing before pointing anything at it — if
sequence is not advancing as you use the app, the TUI has nothing to tail and
will sit on waiting for entries…:
php artisan telescope:install # if it is not installed
# .env: TELESCOPE_ENABLED=true
mysql -e "SELECT COUNT(*), MAX(sequence) FROM myapp.telescope_entries"Create a read-only user rather than reusing the app credentials. The
SELECT-only grant is what makes this tool's read-only guarantee enforced by
the database rather than by convention:
CREATE USER 'telescope_ro'@'127.0.0.1' IDENTIFIED BY '<generated>';
GRANT SELECT ON your_db.telescope_entries TO 'telescope_ro'@'127.0.0.1';
GRANT SELECT ON your_db.telescope_entries_tags TO 'telescope_ro'@'127.0.0.1';
FLUSH PRIVILEGES;Then add the app to apps:. Both sources tail into one list:
buffer: 5000 # entries held in memory across all apps
backfill: 200 # recent entries loaded per app at startup
apps:
- name: myapp
driver: mysql
# loc=UTC matters. Laravel writes created_at in config/app.php's timezone
# (UTC by default) and MySQL DATETIME carries no zone, so the driver has to
# be told how to read it before local-time conversion can be correct.
dsn: "telescope_ro:<password>@tcp(127.0.0.1:3306)/myapp?parseTime=true&loc=UTC"
poll: 300msThe config holds a database password in plaintext and there is no env-var
interpolation yet, so lock it down. config.yml is gitignored, but the file
mode is on you:
chmod 600 ~/Library/Application\ Support/teletui/config.ymlPostgres uses driver: postgres with a postgres://… DSN; sqlite takes a
plain file path. For a database that is not routable, add an ssh: block and
the app opens its own forwarded port — no separate ssh -L to babysit. Note
that host keys are not currently verified, so treat tunnelling as local-only
until that is fixed.
The app looks for a config at config.DefaultPath(), which is not the same
on every platform:
| Platform | Default path |
|---|---|
| macOS | ~/Library/Application Support/teletui/config.yml |
| Linux | ~/.config/teletui/config.yml |
./teletui --help prints the resolved path. Or pass -config <path> and
ignore the default entirely.
./teletui config creates the file in the right place and opens it in your
editor — paste the YAML above, save, and run:
./teletui./teletui # default config path
./teletui -config ./my.yml # explicitA dead app does not stop the others: it shows ✗ in the header while every
other source keeps tailing, and reconnects on its own with exponential backoff.
Nothing is written to any target database, ever. The only statements issued are
four SELECTs in internal/source.
| Key | Action |
|---|---|
↑/k, ↓/j |
move through entries; in a detail view, scroll the stack |
enter |
inspect the row (requests → request detail and auto-correlate; anything else → entry detail; a folded ×N run expands) |
esc |
back: entry detail → request detail → list; also closes the dropdown and clears the filter |
/ |
filter across summary, tags, raw content (works from any screen) |
⇥ |
in detail: cycle Response/Headers/Session; in the list: cycle app filter |
shift+⇥ |
in detail: cycle Payload/Headers; in the list: cycle app back |
[ / ] |
previous / next sidebar section |
a |
cycle the app filter |
p |
pause / resume recording (also click the live segment) |
q / ctrl+c |
quit |
Undocumented-but-kept power keys: c collapse family_hash runs into ×N
rows (c,c refolds), x show index-hidden entries, f toggle follow,
g/G newest/oldest, ctrl+d/ctrl+u half-page, J/K detail scroll,
t/T section cycle, b alias for enter.
Telescope stamps every entry produced during a single request or job with a
shared batch_id. The web UI hides this behind a click-through; here enter
on a request lands you one screen away from the 340 queries it fired, grouped
into tabs by type. That correlation is the reason to build this rather than
tail a log file.
family_hash is Telescope's structural fingerprint: the same statement with
different bindings hashes identically. Collapsing consecutive matches turns an
N+1 into something you see rather than something you count:
10:02:11 storefront query ×340 1.1ms select * from roles where user_id = ?
A folded run keeps the error colour if any member failed, so a slow query
buried inside 340 identical ones is still visible. enter (or a click) on the
×N row expands it to the individual entries; enter on a member opens that
member's detail with its distinct bindings. c,c folds everything back up.
telescope_entries.sequence is a plain auto-increment, so following an app is
WHERE sequence > <high-water mark> on a timer. No timestamp windows, no clock
skew between hosts, no duplicate suppression. Each app polls on its own
goroutine and fans into a single channel, which the UI drains one message at a
time.
Push instead of poll is possible later: write a decorator around Telescope's
EntriesRepository in each app that forwards entries to a socket as well as the
database. That would need a small Composer package installed per app, which is
why polling is the default.
go run ./testdata/seed.go -out /tmp/t.sqlite # regenerate the fixture
go run ./testdata/verify.go -config <cfg> \
-width 142 -height 42 -keys "j,enter,tab" # render headlessly
go run ./testdata/verify.go -config <cfg> \
-keys "enter,click:25:27" -mouse "95,0" # mouse steps work tootestdata/verify.go wires up the same pieces main.go does, feeds the model a
window size, one real poll batch and an input script, then prints
tea.View.Content. Keyboard steps are key names; click:X:Y and
wheel:X:Y:up|down steps drive the mouse, and -mouse "x,y[,wheelup]"
(;-separated) appends pure-mouse steps after -keys. The model is rendered
before every input so clicks resolve against real hit regions. That makes the
render assertable instead of eyeballed — useful because the TUI owns stdout,
so fmt.Println corrupts the display. For debugging the real binary, use
tea.LogToFile and tail -f in a second pane.
Both files live in testdata/, which the go tool excludes from ./..., so
neither can be linked into the binary.
Working. Built and verified against both the sqlite fixture and a live Laravel
app on MySQL with ~900k Telescope entries: entries stream from several sources
into one list, correlation works, and the per-watcher content shapes in
internal/telescope/entry.go were checked against real data — every entry type
present is handled and no field falls through to the raw-JSON fallback.
Known gaps:
- No redaction.
requestentries carrypayload,headers, andsession, and the detail pane renders them verbatim — cookies and bearer tokens included. Fine against a local dev database, which is the only supported use today. Do not point this at an environment with real user data without adding masking toEntry.Detail()first. - SSH host keys are not verified (
ssh.InsecureIgnoreHostKey). Tunnelling is local-only until this is fixed. - No Redis storage driver. Only Telescope's database driver is supported.
- No writes, so pruning still happens via
telescope:prune.