Skip to content

Show the messages of WebSockets that the app creates with Lustro's factory - #69

Merged
Twinsen81 merged 2 commits into
mainfrom
douala-v1/plan-twi-287-implementation
Oct 2, 2026
Merged

Twinsen81 merged 2 commits into
mainfrom
douala-v1/plan-twi-287-implementation

Conversation

@Twinsen81

@Twinsen81 Twinsen81 commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

Summary

Lustro showed a WebSocket only as its handshake, a request with the status 101, because OkHttp sends nothing else through interceptors. This adds the messages.

Setup. One line, next to networkInterceptor():

val sockets: WebSocket.Factory = lustro.webSocketFactory(httpClient) // lustro-noop returns httpClient
sockets.newWebSocket(request, listener)

OkHttp's public API shows a socket's messages in two places, the WebSocketListener and WebSocket.send, so the factory wraps both. The parameter is WebSocket.Factory, not OkHttpClient, so the result also goes to a library that takes a factory (the README lists Ktor's OkHttp engine, Apollo Kotlin, socket.io-client-java, and Scarlet 0.1.x).

What the wrapper promises.

  • The app sees one socket object: the one that newWebSocket returns is the one that every listener call gets. A send from inside a callback is recorded, and a webSocket === mine check holds.
  • request() returns the app's own request. send, close, and queueSize return what OkHttp returns. What the app's listener throws reaches OkHttp.
  • Capture never throws into the app, and a call only puts a task in a queue. A thread of its own cuts the payload at maxBodyCaptureBytes, redacts it, and stores it. While that thread is behind, messages are dropped and counted. The socket never waits.
  • A sent message always comes before the reply to it in the log, also when the reply arrives before send() returns.

The hooks an app already has apply. The capture filter is asked once for each socket and also decides for its handshake. The classifier labels the connection. Pause stops the recording of messages, and Clear removes the connections. A socket that is still open is listed again with its next message, because a socket can live for hours.

Redaction. The URL and the handshake headers go through the Redactor. The text of a message goes through redactBody, and then through the new Redactor.redactWebSocketText, which can return null to store only the size. Redactor.redactWebSocketBinary does the same for a binary message. Both have defaults, so an existing Redactor compiles (the sample's JavaUsage checks this from Java).

Limits. DebugConfig gains maxCaptureWebSockets (100), maxWebSocketEvents (1000 for each connection), and webSocketCaptureBudgetBytes (16 MB for all stored payloads).

Wire protocol 1.3.

Route Shape
GET network/websockets?cursor= Cursor envelope of connections, newest first
GET network/websockets/{id} One connection with its handshake headers
GET network/websockets/{id}/events?cursor=&limit=&direction=&search= Stream envelope: messages and lifecycle events, in order
GET network/websockets/{id}/events/{seq}/payload One payload as it is stored, always an attachment in a sandbox

Every transaction gets webSocketId. The events route uses a new shared envelope, the stream envelope { cursor, status, items?, dropped? }, for a list that only grows at its end: a delta carries only the entries after the cursor. stream-envelope.schema.json describes it, and DebugResponse.streamEnvelope(...) in :lustro-api builds it for any tab.

Console. A switch in the list toolbar opens the WebSockets view: the connections, and for one of them its summary, the handshake headers, the log with a direction filter and a search, and the payload of a message in the JSON tree, as text, or as a hex dump. A WS badge on the handshake's row opens its connection. Markdown copies a connection with its last events.

CLI. lustro net ws list, net ws get <id>, net ws events <id> [--last N] [--sent|--received] [--search TEXT] [--follow], and net ws payload <id> <seq> [-o FILE].

HAR. The entry of a handshake carries the socket's messages in _webSocketMessages (type, time, opcode, data), the keys that the Chrome DevTools importer reads, and _resourceType is websocket.

Decisions to check

  1. Binary payloads are stored by default. HTTP capture keeps no binary body but an image. A binary frame can carry a credential, and the name-based rules can't read it. SECURITY.md says so, and redactWebSocketBinary can change or drop the bytes.
  2. redactBody always runs on message text, and redactWebSocketText runs after it. The first design made redactBody the default of the new method. A test showed the problem: with Redactor by DefaultRedactor, Kotlin delegates the new method to DefaultRedactor, whose default calls its own redactBody, not the override. So an app's stricter redactBody did not apply to messages.
  3. A separate view, not rows in the HTTP list. A socket that opened at app start moves down a newest-first list, and overwrite mode and the ring would need special rules for a handshake that is complete while its socket lives. The handshake row keeps its place in the HTTP list and links to the connection.
  4. Lifecycle events are in the same log as the messages, with a kind. This answers questions such as "did the app call close() before this message" that fields on the connection cannot.
  5. A log cursor counts stored events, and Clear starts a new log. So dropped is exact, and a client with a cursor from before a Clear gets a reset.
  6. A byte budget next to the three limits of the request. 100 connections with 1000 messages of 256 KB are 25.6 GB. Past the budget, the connection that holds the most loses its oldest events.
  7. The third-party setups in the README come from the libraries' sources. I did not compile them. Clients that take only an OkHttpClient (Krossbow, StompProtocolAndroid, JavaPhoenixClient, the SignalR Java client, React Native, centrifuge-java) can't use the factory.
  8. The sample connects to wss://echo.websocket.org/. The echo route of the HTTP fixture host closes each connection after about 10 seconds. The refused upgrade still uses that host, and the CLI end-to-end run answers it with a mock rule, so CI needs no internet for it.

Verification

On an emulator (API 35), with the debug sample:

  • Text and binary messages in both directions, a JSON message with a token (masked in both directions), a 300 KB message (stored as its first 256 KiB, with payloadBytes: 300000), 20 paced messages, and a normal close: closed 1000, closedBy: app, 24 sent and 25 received.
  • cancel(): failed, canceled: true. A refused upgrade: failed, status 403, with the exception text and the headers of the response that refused it. A socket left open: open.
  • Each handshake transaction has webSocketId, and each connection has transactionId.
  • The console ran in headless Chrome, driven over CDP: the list, the log, the JSON tree and the hex dump of a payload, the search (2 of 54 rows), the handshake link, both themes, and widths of 1560 and 1440, with no console error.
  • Also in headless Chrome: a selected request that streams does not draw over the WebSockets view, and the HTTP view comes back with the finished request. With 5000 rows on the page (synthetic events given to the page's poll), a reader who scrolled up keeps the same row at the same place while deltas arrive, also after a delta that reports evicted events; at the end of the log, the newest row stays in view.
  • .github/scripts/cli-e2e.sh passes, with its new WebSocket section.

An earlier build of the capture path ran on a physical device (Android 15), read through the JSON routes: the same message kinds, a server close with code 1002 and its reason, a send that send() refused, cancel(), and a refused upgrade. The final build ran on a physical device (Android 17) that had no internet access, so only the failure path ran there: the messages that the app sent before the connection failed are in the log with the token masked, the failure has the exception text, and a send after the failure is marked as not sent.

Tests:

  • ./gradlew checkDocsVersion assemble test :lustro:lintDebug :sample:lintDebug detekt apiCheck checkFacadeParity passes: 495 unit tests in :lustro, 47 of them new. The capture tests also pass with OkHttp 5.3.2 in place of 4.12.0.
  • pytest in lustro-cli: 269 pass. 16 are new in test_cli_websockets.py, and 15 are new schema checks of the new goldens.
  • node --test lustro/src/test/js/*.test.js: 111 pass, 10 of them new in network-websocket.test.js.

Type of change

  • Bug fix (non-breaking)
  • New feature (non-breaking)
  • Breaking change (public API or wire protocol)
  • Documentation / tooling only

Checklist

  • Tests added or updated for the change
  • ./gradlew apiCheck passes (no unintended public API changes; dump updated if intended)
  • ./gradlew detekt passes
  • CHANGELOG.md updated under [Unreleased]
  • Public API changes are documented with KDoc
  • Wire-protocol or schema changes are reflected in wire-protocol/ and tab OpenAPI fragments
  • All commits are signed off (DCO): git commit -s

Compatibility notes

Minor. The wire protocol goes from 1.2 to 1.3, and every change is an addition. :lustro-api gains DebugResponse.streamEnvelope, WebSocketMessageInfo, and two Redactor methods with defaults; the API dump is updated. Lustro gains webSocketFactory, and DebugConfig three values, in both runtime artifacts.

…ctory

Lustro showed a WebSocket only as its handshake, because OkHttp sends
nothing else through interceptors. Lustro.webSocketFactory(client) now
returns a WebSocket.Factory that wraps the client's sockets and listeners,
and records each message and lifecycle event in the Network tab.

- Capture: the app gets one socket object from newWebSocket and in every
  listener call, return values and listener exceptions are OkHttp's, and a
  call only queues a task. A thread of its own cuts, redacts, and stores
  the payloads, and drops messages while it is behind.
- Hooks: the capture filter decides once for a socket and its handshake,
  redactBody covers the text of a message, and Redactor gains
  redactWebSocketText and redactWebSocketBinary, both with defaults.
  lustro-noop returns the client itself.
- Limits: DebugConfig gains maxCaptureWebSockets, maxWebSocketEvents, and
  webSocketCaptureBudgetBytes.
- Wire protocol 1.3: the websockets routes, webSocketId on the transaction
  of a handshake, and the stream envelope for a list that only grows at
  its end, with DebugResponse.streamEnvelope for any tab.
- Console: a WebSockets view in the Network tab. CLI: lustro net ws list,
  get, events, and payload. HAR: _webSocketMessages on the handshake.
- The sample has a WebSocket section against an echo server, and the CLI
  end-to-end script checks a socket whose handshake a mock rule refuses.

Verified on an emulator (API 35): text and binary messages in both
directions, a message over the capture cap, a normal close, cancel(), and
a refused upgrade, in the console and with the CLI. The CLI end-to-end
script passes there. An earlier build of the capture path was verified on
a physical device (Android 15) through the JSON routes.

Signed-off-by: Evgenii Plokhov <plokhov@gmail.com>
The HTTP detail no longer draws over the WebSockets view. A refresh of the
selected request is not scheduled while the other view is shown, a view
switch drops a detail request that is on its way, and the HTTP view loads
the detail again when it comes back.

A delta to a connection's log adds only its rows and removes the rows the
page no longer keeps. This also applies at the page's row limit and when
events were evicted before the page got them, so the reader's place stays
once they scroll up. The end of the log stays in view when the summary or
the headers render after the events. A connection's summary is rendered
only when it changed.

The copy and hex dump caches hold only what the pane shows.

A poll for the events of a connection that the app no longer has shows a
note in the log, and does not mark the console as disconnected.

A refused upgrade keeps the protocol and the redacted headers of its
response.

The transactions cursor moves when a connection is listed or evicted,
because a transaction's webSocketId comes from the listed connections.

Signed-off-by: Evgenii Plokhov <plokhov@gmail.com>
@Twinsen81
Twinsen81 marked this pull request as ready for review October 2, 2026 13:24
@Twinsen81
Twinsen81 merged commit 04ab969 into main Oct 2, 2026
8 checks passed
@Twinsen81
Twinsen81 deleted the douala-v1/plan-twi-287-implementation branch October 2, 2026 13:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant