
Fifteen years building engineering platforms, currently focused on advanced AI infrastructure at yeet. I love turning the deeply complex topics into something everyone can understand. I relate deeply with the core yeet philosophy that you can just build things.
Quick answer. Your WebSocket test asserts on the object your client library handed you, which has already been unmasked, de-fragmented, decompressed and possibly reordered. To see what actually crossed the connection, read the plaintext at the TLS boundary with a uprobe rather than trying to decrypt packets:
yeet run src/main.jsx -- --pid <pid>with wssnoop attaches to a running process and shows the real frames, opcodes and close codes. Wireshark does the same job offline if you can restart the client withSSLKEYLOGFILEset.
I build eBPF tooling for Linux hosts, and the WebSocket bug I get asked about most is never a bug in the framing. It is a test that passed for six weeks against an assumption nobody rechecked. The client library returned an object, somebody wrote expect(msg.type).toBe("snapshot"), and the suite went green in CI for a month and a half while the server quietly started sending an extra heartbeat first. I have not run an exchange feed in production and I am not going to tell you how to design your reconnect logic. What I keep seeing is narrower and duller: the layer where the assertion happens is several transformations away from the layer where the behavior happens, and almost nobody looks at the gap until something breaks in a way the mock cannot reproduce.
Start with ordering and compression, not payload. Those two account for most of the local-versus-CI split, and both are invisible from inside the client library. A test that awaits a specific message passes locally because the local server sent it second and fails in CI because it arrived fourth, and any client that buffers hides the difference behind an identical-looking object. Compression is the sharper trap: a server that negotiates permessage-deflate in one environment and not the other delivers byte-for-byte identical decoded payloads from completely different wire bytes, so a length assertion measured in the client agrees while a length measured on the connection disagrees.
The reason this is hard to chase from the test is that a WebSocket client is a stack of transformations, and your assertion sits on top of all of them. Between the connection and your callback, the library unmasks client-to-server frames, reassembles fragments into whole messages, inflates compressed payloads, decodes UTF-8, parses JSON, and in some libraries coalesces or queues. Each step is a place where the thing you asserted on stops resembling the thing that crossed the wire.
| Layer | What it sees | What it has already lost |
|---|---|---|
Your assertion (expect(msg.type)) | a parsed object | framing, ordering, compression, opcodes, close codes |
| Client library callback | a decoded message | frame boundaries, masking, fragment count, wire size |
TLS plaintext boundary (SSL_write / SSL_read) | the real frame bytes | nothing WebSocket-level |
TCP packets (tcpdump) | TLS records | all message content, because it is ciphertext |
The two useful rows are the middle ones, and they are the two nothing in your test suite reaches by default. The plaintext boundary is where a WebSocket frame is fully formed and not yet interpreted, which makes it the only place you can check masking, fragmentation, opcode and compression ratio against what the protocol requires.
Read the plaintext before it gets encrypted, instead of trying to decrypt the ciphertext afterwards. This is the distinction that makes the whole problem tractable, and it is worth being precise about because "seeing inside TLS" sounds like it should require key material. It does not. A TLS library exposes a function boundary where your application hands over plaintext, and a probe on that boundary reads the buffer as the application passed it.
Concretely, for an OpenSSL-linked process: SSL_write(ssl, buf, num) receives plaintext in buf, so a uprobe at function entry reads the outbound bytes. SSL_read(ssl, buf, num) fills buf by the time it returns, so a uretprobe reads inbound bytes with the length taken from the return value. No decryption happens at any point, and no private key is involved, because the bytes were never encrypted at the moment they were read.
# which process is holding the socket, and what TLS library does it use?
ss -tnp | grep -i estab # find the pid behind the connection
readlink /proc/<pid>/exe # the executable
ldd "$(readlink /proc/<pid>/exe)" | grep -i ssl # dynamic libssl? that is what to hook
ss -tnp lists established TCP sockets with the owning process, which is the step most WebSocket guides skip past even though "which process is that connection" is where a real investigation starts. If ldd names a libssl.so, the symbols live there. If it prints nothing, the runtime statically linked its TLS and the symbols are baked into the executable, which is the normal case for official Node builds and the reason the binary to probe is sometimes node itself.
That difference is not a detail; it decides whether your capture works after a production build, and it varies by runtime, so check yours before an incident makes it urgent.
It depends entirely on where your runtime keeps its TLS symbols, and the answer splits cleanly into immune and fragile. A uprobe attaches by symbol name, so the question is whether strip removed the name.
Dynamically linked OpenSSL is immune. The SSL_read and SSL_write symbols live in libssl's .dynsym section, which the dynamic linker needs at runtime, so strip never removes them. Python websockets, dynamically linked Rust native-tls and most C++ keep decoding no matter how aggressively the application itself is built.
Go, rustls and statically linked OpenSSL are fragile, because their symbols live in .symtab, which a full strip does remove. strip, Cargo's strip = true, and go build -ldflags="-s -w" all wipe it, and a name-based attach then has nothing to resolve. The fix for a Rust service you control is one line: use strip = "debuginfo" in [profile.release] rather than strip = true. That drops the DWARF debug info, which is most of the binary size, and keeps the symbol table, so hooks still resolve.
| Runtime | Boundary hooked | Survives a full strip |
|---|---|---|
OpenSSL via dynamic libssl (Python, Ruby, C/C++, Rust native-tls) | SSL_read/SSL_write plus the SSL_read_ex/SSL_write_ex pair CPython uses | Yes, symbols are in .dynsym |
| OpenSSL linked statically into the executable (official Node builds) | the same symbols, baked into the exe | No, .symtab only |
| Go standard library | crypto/tls.(*Conn).Read and .Write, register ABI, reads keyed by goroutine id | No with -s -w, though .gopclntab remains in principle |
rustls (tokio-tungstenite, any pure-Rust TLS) | PlaintextSink::write and CommonState::take_received_plaintext, resolved from the demangled name | Only with strip = "debuginfo" |
Two things in that table are the kind of detail that decides an afternoon. Go's reads are keyed by goroutine id because a goroutine can migrate between OS threads mid-call, so pairing a read's entry with its exit by thread id gives you interleaved garbage. And rustls symbol names carry a per-build hash, so an exact-name match fails between compilations; resolving from the demangled name is what makes the attach survive a rebuild.
Five routes, and three of them beat a kernel probe for the case they fit. Pick by what you already have: a browser tab, a process you can restart, a proxy you can route through, or a running production process you cannot touch.
| Tool | Approach | Sees inside wss:// | Needs | Best for |
|---|---|---|---|---|
| wssnoop with yeet | uprobe at the TLS plaintext boundary | Yes, no keys involved | Linux, the pid, symbols resolvable | a running process you cannot restart or reroute |
| Wireshark / tshark | packet capture plus TLS key log decryption | Yes, given SSLKEYLOGFILE | restarting the client with the variable set | offline analysis, sharing a capture file |
| mitmproxy | terminating proxy in the middle | Yes, it holds both TLS sessions | routing the client through it, trusting its CA | scripted interception and rewriting |
| Chrome DevTools | browser-internal instrumentation | Yes, for that tab | the traffic to be in a Chrome tab | front-end work |
Playwright routeWebSocket | interception at the browser boundary | Yes, for connections it controls | a test driving the browser | mocking scenarios in an end-to-end test |
The ordering matters more than the rows. If your traffic is in a browser tab, DevTools already shows you frames and you should not be writing a probe. If you can restart the client, a TLS key log plus Wireshark is a well-trodden path with a GUI and a shareable capture file. If you need to change traffic rather than watch it, mitmproxy is built for exactly that and a read-only probe is not.
The case where a probe is the right tool is narrower and specific: a process that is already running, holding connections you cannot recreate, that you cannot restart with new environment variables and cannot reroute through a proxy. That is a production process at 2am, or a container you did not build, or a client whose reconnect behavior is the thing under investigation and therefore cannot be disturbed by a restart.
Start the client with SSLKEYLOGFILE=/tmp/keys.log set, capture the traffic, then point Wireshark at that file under Edit, Preferences, Protocols, TLS, in the preference Wireshark names "(Pre)-Master-Secret log filename" (tls.keylog_file). Wireshark cannot break TLS, but its wiki describes the key log file as "a universal mechanism that always enables decryption, even if a Diffie-Hellman (DH) key exchange is in use," generated "by applications such as Firefox, Chrome and curl when the SSLKEYLOGFILE environment variable is set." Point Wireshark at that file and its WebSocket dissector gives you websocket.opcode, websocket.mask, websocket.masking_key, websocket.payload and a websocket.pmc field for per-message compression.
The route that does not work on a modern endpoint is the one people try first. Wireshark's own wiki says handing it the server's RSA private key "only works in a limited number of cases," requiring that the cipher suite is "not using (EC)DHE" and that the protocol is "SSLv3, (D)TLS 1.0-1.2. It does not work with TLS 1.3." Any current wss:// endpoint negotiates ephemeral keys, so the private key buys you nothing and the key log is the only path.
Two practical notes. tshark --export-tls-session-keys <keyfile> writes the keys out of a capture you already have, and it is documented in the tshark User's Guide rather than the man page, which is where people go looking and find only --export-objects. And editcap --inject-secrets inserts a key log into a pcapng as a Decryption Secrets Block, which makes the capture self-decrypting and is the right way to hand a colleague something that just opens.
The constraint is the restart. SSLKEYLOGFILE is read when the process starts, so a running production process that already holds the connections you care about cannot be enrolled without killing it, and killing it destroys the state you were investigating.
Almost certainly the protocol-level facts, because your client library consumed them before your assertion ran. A test suite that asserts on decoded objects cannot observe masking, fragment counts, opcode sequence, compression ratio or the real close code, and each of those is a place where a client can be wrong in a way that looks fine in a decoded payload.
The close code is the clearest example. RFC 6455 puts a two-byte big-endian status code at the start of a CLOSE frame's payload, optionally followed by a UTF-8 reason. Many client libraries surface 1006 for anything they did not see cleanly, and 1006 means "abnormal closure" rather than a code the server sent. So a test asserting code === 1006 is asserting that the library got confused, which it will keep doing identically whether the server sent 1011 for an internal error, 1013 for try-again-later, or nothing at all. Reading the CLOSE frame off the connection is the difference between "the connection dropped" and "the server told us why it dropped."
Masking is the second. RFC 6455 requires that "a client MUST mask all frames that it sends to the server" and that "a server MUST NOT mask any frames that it sends to the client," with the masking key drawn from a strong entropy source. A client implementation that reuses a masking key, or masks with a predictable value, produces payloads that decode perfectly and violate the spec. Nothing in a decoded-object assertion can see it.
Fragmentation is the third and the one that bites hardest in a rewrite. A message split across continuation frames arrives at your callback as a single string, so a client that reassembles correctly and a client that happens to receive everything in one frame are indistinguishable from the test. Change a buffer size, deploy behind a different proxy, and the fragmenting path runs for the first time in production.
| What you assert on | What it cannot catch |
|---|---|
msg.type === "snapshot" | the heartbeat that now arrives first |
payload.length === 512 | that the wire carried 210 compressed bytes |
code === 1006 | the 1011 the server actually sent |
JSON.parse(msg) succeeds | a reused masking key, a spec violation |
| the decoded string | that it arrived as four continuation frames |
Capture the live traffic and serialize the decoded messages one JSON object per line. This is the part of the workflow that changes how a suite behaves over time, because a mock encodes what you believe the server sends and a fixture records what it actually sent. The difference only shows up months later: when the real server adds a field or reorders its opening messages, nothing fails against a mock, and the test keeps passing against a stale expectation until someone debugs production.
wssnoop writes JSON Lines from its inspector, one record per message, and the record shape is built for exactly this: a sequence number, the direction as in or out, the opcode name, the payload length, a compressed flag when permessage-deflate was negotiated, an inflateError when decompression failed, and then the payload as parsed json when it parses, raw text when it does not, or base64 when the bytes are not text at all. Compressed messages are recorded already inflated, the way the application would see them, with the compression provenance kept alongside.
JSON Lines is the right shape rather than one big JSON array because a test can stream it: read a line, replay a message, assert, repeat. Ordering is chronological, oldest first, which is the order a replay needs and the opposite of the order a live inspector displays.
# capture a session from a running process, then keep the probe running
# under the daemon so the terminal is free
yeet run src/main.jsx -- --pid $(pgrep -f md-gateway) # attach, inspect, copy JSONL
yeet run -d src/main.jsx -- --pid $(pgrep -f md-gateway) # or detach and leave it up
yeet ps # what is running
--pid takes several pids at once (--pid a,b,c), which matters when the behavior you are chasing spans processes: an order router and a market-data gateway holding separate connections to the same venue decode together in one view. The detached form matters for a different reason, covered below.
Yes, and it does not have to occupy a terminal. yeet run -d spawns the probe detached under the daemon and returns your prompt, yeet ps lists what is running, yeet attach reconnects to a running probe's output without stopping it, and yeet kill ends it. For a capture you intend to keep across a long reproduction, a script can be registered with the daemon as a service with named units and a restart policy of always or on-failure, and that definition is persisted to disk so it survives a daemon restart. yeet service list and yeet service tree show what is registered.
A detached probe can also do something when it sees the thing you are waiting for, which is the part that turns a long reproduction from babysitting into waiting. The runtime exposes an alert platform call to scripts, so a probe watching for a specific close code or a malformed frame can fire an alert at the moment it matches rather than requiring you to be looking. Combined with a restart policy, that is a capture you can walk away from.
What you still do not get is a metrics pipeline. There is no time-series store, no query language and no view wider than the host the probe is attached to, and nothing is aggregated across boxes. Where a capture keeps its messages, it keeps them because the script did something with them. That boundary is deliberate: the in-kernel capture filter means cost scales with matched events rather than total traffic, precisely because there is no collection pipeline behind it.
The capture filter earns its keep on a long reproduction. wssnoop's focus control writes the BPF capture filter live, so the probe emits only the focused connection's events and every other connection goes silent in the kernel rather than being filtered in userspace. On a process holding forty connections while you chase one, that is the difference between a readable capture and a firehose.
Split your suite by what each layer needs, because the decode logic and the capture need completely different environments and conflating them makes the whole suite un-runnable on a hosted runner. This is the split that decides whether your WebSocket tests run on every commit or only when somebody remembers.
Frame decoding is pure logic and needs nothing. Construct the frame bytes in memory, feed them through your decoder, assert on the result. No socket, no server, no privilege, no kernel features. wssnoop's own unit tests work this way: they build RFC 6455 frames byte by byte, including masked and fragmented cases, and run with yeet run test/lib.test.js against no BPF, no daemon and no UI. That suite runs anywhere.
Capture is the opposite and there is no way around it. Loading an eBPF program needs a privileged kernel, the uprobe targets need the language toolchains installed, and the runtime has to be present. wssnoop's integration tests attach the Go and rustls taps to a live workload and assert plaintext capture in both directions, and the honest constraint is visible in how they are wired: the workflow is workflow_dispatch only, targets a self-hosted runner, and skips rather than failing red when the yeet runtime is absent. A GitHub-hosted runner does not ship it.
| Test layer | Needs | Runs on a hosted runner | What it proves |
|---|---|---|---|
| Frame decode from in-memory bytes | nothing | Yes | your parser handles masking, fragments, compression, opcodes |
| Replay from a captured JSONL fixture | the fixture file | Yes | your handler survives the real message sequence |
| Live capture against a real workload | privileged kernel, toolchains, runtime | No, self-hosted | the taps resolve symbols and pair reads correctly |
The middle row is the one most suites are missing, and it is the cheapest of the three. A fixture captured once from a real session is a file; replaying it needs no kernel, no privilege and no network. It catches the class of bug that mocks structurally cannot, because the sequence came from the server rather than from your beliefs about the server.
Attach from the host and let the probe resolve the container's binary through the process's mount namespace at /proc/<pid>/root/. The reason this needs saying is that the obvious approach fails for a subtle reason: a containerized Node image bundles its own OpenSSL rather than linking the host's, so the symbols you need are inside the container filesystem and the host's libssl is the wrong file entirely.
Reaching through /proc/<pid>/root/ gets the container's own node or libssl from the host without entering the namespace, which means one probe on the host can cover processes in several containers at once. wssnoop resolves the target this way automatically when given a --pid that happens to be containerized, and groups the resulting connections under a container header with the image name, resolved through the system graph's docker field.
If you are on Kubernetes and want protocol tracing across a cluster rather than one host, this is the wrong tool and Pixie is the usual recommendation, with one caveat worth checking before you plan around it: Pixie's supported protocol list is "HTTP, HTTP2, DNS, NATS, MySQL, PostgreSQL, Cassandra, Redis, Kafka, AMQP, MongoDB," and WebSocket is not on it. The page notes that "additional protocols are under development." Pixie also requires Kubernetes v1.21 or later and deploys as a DaemonSet, so it is not an option on a standalone Linux host at all.
Three causes, in the order they actually occur. Each has a distinct signature, so you can tell them apart without guessing.
SSL* pointer addresses, so a new handshake is what resets the stream identity. A client that recycles connections self-heals within a minute; a long-lived connection does not, and you may have to wait for a reconnect or force one..symtab for a name-based attach to search. The signature here is different from the first case: the attach itself fails rather than the capture coming up empty. Check with nm -D on the library or readelf -s on the executable before assuming the probe is at fault.SSL_write to hook, and a Go binary has neither. If the process shows up but decodes to nothing, that is the marker for a TLS stack that is not one of the hooked ones, and it is reported as opaque rather than hidden, precisely so that "no messages" and "cannot see this process" are distinguishable states.A fourth case is rarer and misleads differently, because it looks like corruption rather than absence. The capture takes 4KB per SSL call, which covers a single TLS record comfortably, so a larger coalesced read gets reported as truncated rather than emitting garbage that a decoder would misparse into nonsense frames.
Mock when you are testing your own client's behavior; replay a fixture when you are testing your agreement with a server you do not control. Mocking is right for most of a suite, because a mock is deterministic, fast, and needs no network. Driving your reconnect logic through six failure modes, checking that a malformed frame is rejected, asserting that a backpressure path engages: all of that is better served by frames you construct than by frames you captured, because you need cases the real server will not produce on demand.
Playwright is the strong option for the browser-side version of this. Its routeWebSocket API hands your test a WebSocketRoute that behaves, in Playwright's description, "like an actual server would do," and by default "the routed WebSocket will not connect to the server," so the whole exchange is yours to script. connectToServer opts back into a real upstream when you want both. That is a genuinely better tool than a kernel probe for driving a front-end through scenarios.
The division worth holding: mocks test your handling of cases you can imagine, and captured fixtures test your agreement with a server you do not control. A suite with only mocks is confident about the wrong thing. A suite with only fixtures cannot test failure modes the server never sends. Both, at different layers, is the shape that catches things.
If the traffic is in a browser tab, open Chrome DevTools and read the Messages tab, remembering it keeps only the last 100 messages. If you can restart the client, set SSLKEYLOGFILE, capture with tshark, and read it in Wireshark, which gives you a shareable file and a real GUI. If you need to modify traffic rather than watch it, use mitmproxy, accepting that it terminates TLS and needs its CA trusted. If you are testing your own client's behavior against cases you invent, mock it, and use Playwright's routeWebSocket for the browser side. If you need protocol tracing across a Kubernetes cluster, look at Pixie, and check first whether WebSocket has arrived on its protocol list. If the process is already running, holds connections you cannot recreate, and cannot be restarted or rerouted, that is when a uprobe at the TLS boundary is the only thing that works, and wssnoop is the ready-made version. If your case is close to that but the shape is wrong, the underlying runtime is yeet and the probe is about eighty lines of JavaScript.
The failure mode worth avoiding is subtler than picking the wrong tool: it is a green suite that has never once compared itself against a real server's output. Capture one session, keep it as a fixture, replay it on every commit. That one file catches the drift that no amount of mock maintenance will.
Because any modern endpoint negotiates ephemeral keys. Wireshark's own documentation says the RSA private key route "only works in a limited number of cases," requiring that the cipher suite is "not using (EC)DHE" and that the protocol is "SSLv3, (D)TLS 1.0-1.2," and states it "does not work with TLS 1.3" at all. A TLS key log file is the only path that works regardless of key exchange.
The usual causes are ordering and timing rather than payload. A test that awaits a specific message can pass locally because the local server happened to send it second and fail in CI because it arrived fourth, and a client library that buffers or coalesces hides the difference. Compression is the other common cause: a server that negotiates permessage-deflate in one environment and not the other produces identical decoded payloads from different wire bytes.
A mock encodes what you believe the server sends. A fixture records what it actually sent. Mocks drift silently, because nothing fails when the real server adds a field or changes a message order, and the test keeps passing against the stale expectation. A fixture captured from a live session carries the real field names, the real ordering and the real framing, so replaying it fails when the server changes.
Loading an eBPF program requires privilege, but that does not have to mean running your tooling as root. With yeet, a daemon holds the privileged BPF load, so yeet run itself never takes sudo. Packet capture tools follow a different model: tcpdump and tshark need either root or the CAP_NET_RAW capability granted to the binary.
Yes, by reading the plaintext before it is encrypted rather than decrypting the ciphertext. A uprobe on a TLS library's write function sees the buffer the application passed in, which is plaintext at that moment, and a uretprobe on the read function sees the buffer after it has been filled. No key material is involved because no decryption happens.
For ws:// on a plaintext connection, yes, though you have to reassemble frames yourself. For wss://, no. tcpdump captures packets off the wire, and the wire carries TLS records, so the payload is ciphertext. You get connection timing, byte counts and TCP behavior, but no message content.
Capture a live session and serialize the decoded messages, one JSON object per line. wssnoop does this from its inspector, writing JSON Lines with the direction, opcode, length, compression flag and decoded payload per message. JSON Lines is the useful shape because a test can read it line by line and replay messages in the order they arrived.
Playwright can intercept and mock WebSocket connections through page.routeWebSocket, which hands your test a WebSocketRoute that behaves, in Playwright's words, like an actual server would do. By default a routed WebSocket does not connect to the real server at all, so you are asserting on messages your test supplied. That is interception at the browser boundary rather than observation of a real connection, and connectToServer opts back into a live upstream.
The close code is a two-byte big-endian integer at the start of a CLOSE frame payload, optionally followed by a UTF-8 reason string, per RFC 6455. Many client libraries surface 1006 (abnormal closure) for anything they did not see cleanly, which hides the code the server actually sent. Reading the CLOSE frame off the connection shows the real code and reason.
It compresses message payloads with DEFLATE, per RFC 7692, and sets the RSV1 bit on the first frame of a compressed message. The consequence for debugging is that the bytes on the connection do not match the bytes your application sees, so a payload-length assertion measured on the wire disagrees with one measured in the client.
A uprobe fires on every call to the traced function, so cost scales with how often the process reads and writes rather than with total traffic on the host. That makes a chatty socket more expensive to trace than a quiet one at the same byte rate. An in-kernel capture filter is what keeps it bounded: filtering to one connection in the kernel means the other connections cost nothing beyond the probe entry itself, rather than being copied to userspace and discarded there.
Yes, and it is the right default for unit tests: feed recorded frames through your decoding and handling logic directly, with no socket involved. wssnoop's own unit tests do this, constructing frames in memory and asserting on the decoded result, which is why they need no eBPF, no daemon and no privilege to run.
0x0 continuation, 0x1 text, 0x2 binary, 0x8 close, 0x9 ping, 0xA pong, with 0x3-7 and 0xB-F reserved); and the CLOSE payload carrying a two-byte big-endian status code followed by an optional UTF-8 reason.permessage-deflate, the extension that "compresses the payload data portion of WebSocket data messages on a per-message basis using parameters negotiated during the opening handshake," which is why a wire byte count and an application byte count legitimately disagree and why the RSV1 bit marks a compressed message.SSLKEYLOGFILE environment variable is set." The RSA private key route by contrast "only works in a limited number of cases," requiring a non-(EC)DHE cipher suite and "SSLv3, (D)TLS 1.0-1.2. It does not work with TLS 1.3."websocket.opcode, websocket.mask, websocket.masking_key, websocket.payload with text and binary variants, and websocket.pmc for per-message compression (2.6.0 onward) plus a websocket.decompression.failed expert info field, confirming the dissector handles permessage-deflate rather than showing compressed payloads as opaque.--export-tls-session-keys <keyfile> to "export TLS Session Keys to a file named keyfile," a flag absent from the tshark man page, where the only export option is --export-objects <protocol>,<destdir>. The companion move is editcap's --inject-secrets, which "inserts the contents of <file> into a Decryption Secrets Block (DSB) within the pcapng output file" and supports the tls TLS Key Log type, making a capture self-decrypting before you hand it to somebody else.-E for IPsec ESP. It also notes that "reading packets from a network interface may require that you have special privileges."page.routeWebSocket() or browserContext.routeWebSocket(), it "allows to handle the WebSocket, like an actual server would do," and crucially "by default, the routed WebSocket will not connect to the server. This way, you can mock entire communication over the WebSocket," with connectToServer() opting into a real upstream. Methods include onMessage, send, close and onClose.SSL_read/SSL_write uprobes with RFC 6455 reassembly in JavaScript, the TLS runtime coverage table for OpenSSL, Go and rustls, the stripped-binary behavior including the strip = "debuginfo" guidance, the 4KB per-call capture cap, and the JSON Lines export whose records carry direction, opcode, length, compression flag and decoded payload.Built with yeet, a JS runtime for writing eBPF programs on Linux machines. Join us on discord.