Repository navigation
Show the messages of WebSockets that the app creates with Lustro's factory - #69
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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():OkHttp's public API shows a socket's messages in two places, the
WebSocketListenerandWebSocket.send, so the factory wraps both. The parameter isWebSocket.Factory, notOkHttpClient, 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.
newWebSocketreturns is the one that every listener call gets. Asendfrom inside a callback is recorded, and awebSocket === minecheck holds.request()returns the app's own request.send,close, andqueueSizereturn what OkHttp returns. What the app's listener throws reaches OkHttp.maxBodyCaptureBytes, redacts it, and stores it. While that thread is behind, messages are dropped and counted. The socket never waits.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 throughredactBody, and then through the newRedactor.redactWebSocketText, which can returnnullto store only the size.Redactor.redactWebSocketBinarydoes the same for a binary message. Both have defaults, so an existingRedactorcompiles (the sample'sJavaUsagechecks this from Java).Limits.
DebugConfiggainsmaxCaptureWebSockets(100),maxWebSocketEvents(1000 for each connection), andwebSocketCaptureBudgetBytes(16 MB for all stored payloads).Wire protocol 1.3.
GET network/websockets?cursor=GET network/websockets/{id}GET network/websockets/{id}/events?cursor=&limit=&direction=&search=GET network/websockets/{id}/events/{seq}/payloadEvery 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: adeltacarries only the entries after the cursor.stream-envelope.schema.jsondescribes it, andDebugResponse.streamEnvelope(...)in:lustro-apibuilds 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
WSbadge 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], andnet 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_resourceTypeiswebsocket.Decisions to check
SECURITY.mdsays so, andredactWebSocketBinarycan change or drop the bytes.redactBodyalways runs on message text, andredactWebSocketTextruns after it. The first design maderedactBodythe default of the new method. A test showed the problem: withRedactor by DefaultRedactor, Kotlin delegates the new method toDefaultRedactor, whose default calls its ownredactBody, not the override. So an app's stricterredactBodydid not apply to messages.kind. This answers questions such as "did the app callclose()before this message" that fields on the connection cannot.droppedis exact, and a client with a cursor from before a Clear gets areset.OkHttpClient(Krossbow, StompProtocolAndroid, JavaPhoenixClient, the SignalR Java client, React Native, centrifuge-java) can't use the factory.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:
token(masked in both directions), a 300 KB message (stored as its first 256 KiB, withpayloadBytes: 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.webSocketId, and each connection hastransactionId..github/scripts/cli-e2e.shpasses, 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 checkFacadeParitypasses: 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.pytestinlustro-cli: 269 pass. 16 are new intest_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 innetwork-websocket.test.js.Type of change
Checklist
./gradlew apiCheckpasses (no unintended public API changes; dump updated if intended)./gradlew detektpassesCHANGELOG.mdupdated under[Unreleased]wire-protocol/and tab OpenAPI fragmentsgit commit -sCompatibility notes
Minor. The wire protocol goes from 1.2 to 1.3, and every change is an addition.
:lustro-apigainsDebugResponse.streamEnvelope,WebSocketMessageInfo, and twoRedactormethods with defaults; the API dump is updated.LustrogainswebSocketFactory, andDebugConfigthree values, in both runtime artifacts.