Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ WiretapKMP can inspect **Server-Sent Events (SSE)** streams — log every connec

### Ktor

Install the SSE plugin and wrap your session:
Install the SSE plugin — sessions are wrapped automatically:

```kotlin
@OptIn(ExperimentalWiretapSseApi::class)
Expand All @@ -96,8 +96,7 @@ val client = HttpClient {
}

client.sse("https://api.example.com/stream") {
val session = this.wiretapped()
session.incoming.collect { event ->
incoming.collect { event ->
println("Event: ${event.event} — ${event.data}")
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import io.ktor.serialization.ContentConverter
import kotlinx.serialization.json.Json
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
import kotlin.time.Duration.Companion.seconds

@OptIn(ExperimentalWiretapSseApi::class)
val sampleAppModule = module {
Expand All @@ -43,9 +42,7 @@ val sampleAppModule = module {
single {
HttpClient {
install(HttpTimeout)
install(WebSockets) {
pingIntervalMillis = 5.seconds.inWholeMilliseconds
}
install(WebSockets)

install(SSE)
install(WiretapKtorWebSocketPlugin)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.skymansandy.wiretap.helper.markers.ExperimentalWiretapSseApi
import dev.skymansandy.wiretap.plugin.sse.wiretapped
import dev.skymansandy.wiretapsample.model.SampleMessage
import dev.skymansandy.wiretapsample.model.SampleMessage.MessageType
import dev.skymansandy.wiretapsample.model.SseSampleActions
Expand All @@ -23,7 +21,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

@OptIn(ExperimentalWiretapSseApi::class)
class KtorSseViewModel(
private val client: HttpClient,
) : ViewModel(), SseSampleActions {
Expand Down Expand Up @@ -71,13 +68,12 @@ class KtorSseViewModel(
headers.remove("Accept")
headers.append("Accept", "text/event-stream")
}) {
val session = this.wiretapped()
_isConnected.value = true
_isConnecting.value = false
eventLog.add(SampleMessage(MessageType.System, "Connected!"))

try {
session.incoming.collect { event ->
incoming.collect { event ->
val text = buildString {
event.event?.let { append("[$it] ") }
append(event.data ?: "")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dev.skymansandy.wiretap.plugin.ws.WiretapWebSocketSession
import dev.skymansandy.wiretap.plugin.ws.wiretapped
import dev.skymansandy.wiretapsample.model.SampleMessage
import dev.skymansandy.wiretapsample.model.SampleMessage.MessageType
import dev.skymansandy.wiretapsample.model.WsSampleActions
import dev.skymansandy.wiretapsample.model.wsServers
import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession
import io.ktor.client.plugins.websocket.webSocket
import io.ktor.websocket.CloseReason
import io.ktor.websocket.Frame
Expand Down Expand Up @@ -43,7 +42,7 @@ class KtorWebSocketViewModel(
override val messageLog: SnapshotStateList<SampleMessage> = mutableStateListOf()

private var wsUrl = wsServers[0].first
private var session: WiretapWebSocketSession? = null // nullable because it's only set during active connection
private var session: DefaultClientWebSocketSession? = null
private var connectionJob: Job? = null
private val exceptionHandler = CoroutineExceptionHandler { _, _ -> }

Expand All @@ -69,14 +68,13 @@ class KtorWebSocketViewModel(
connectionJob = viewModelScope.launch(Dispatchers.IO + exceptionHandler) {
try {
client.webSocket(wsUrl) {
val wrapped = this.wiretapped()
session = wrapped
session = this
_isConnected.value = true
_isConnecting.value = false
messageLog.add(SampleMessage(MessageType.System, "Connected!"))

try {
for (frame in wrapped.incoming) {
for (frame in incoming) {
if (frame is Frame.Text) {
val text = frame.readText()
messageLog.add(SampleMessage(MessageType.Received, text))
Expand Down
4 changes: 2 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ Full WebSocket lifecycle tracking:
- Connection open/close/failure with status transitions
- Close codes and reasons
- Every sent and received message (text and binary) with timestamps and byte counts
- Ktor: wrap session with `wiretapped()` for automatic message interception
- Ktor: automatically intercepted by `WiretapKtorWebSocketPlugin`
- OkHttp: wrap listener with `WiretapOkHttpWebSocketListener` for automatic event capture

## SSE (Server-Sent Events) Logging
Expand All @@ -114,7 +114,7 @@ Full SSE connection lifecycle tracking:

- Connection open/close/failure with status transitions
- Every incoming event with event type, data payload, event ID, retry interval, and byte count
- Ktor: wrap SSE session with `wiretapped()` for automatic event interception
- Ktor: automatically intercepted by `WiretapKtorSsePlugin`
- OkHttp: wrap listener with `WiretapOkHttpEventSourceListener` for automatic event capture

## API Mocking
Expand Down
65 changes: 2 additions & 63 deletions docs/ktor/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,40 +39,7 @@ HttpClient {
val WiretapKtorWebSocketPlugin: ClientPlugin<Unit>
```

Intercepts WebSocket upgrades (101 responses) to log connections.

---

## wiretapped()

```kotlin
suspend fun DefaultClientWebSocketSession.wiretapped(): WiretapWebSocketSession?
```

Extension to wrap a Ktor WebSocket session for message logging. Returns `null` if `WiretapKtorWebSocketPlugin` is not installed.

---

## WiretapWebSocketSession

```kotlin
class WiretapWebSocketSession(
val delegate: DefaultClientWebSocketSession,
)
```

### Properties

| Property | Type | Description |
|----------|------|-------------|
| `incoming` | `ReceiveChannel<Frame>` | Incoming frames with automatic logging (all frame types) |

### Methods

| Method | Description |
|--------|-------------|
| `suspend fun send(frame: Frame)` | Logs the frame and sends via delegate |
| `suspend fun close(code: Short, reason: String?)` | Logs status as Closed and closes the delegate |
Intercepts WebSocket upgrades (101 responses) and automatically wraps sessions to log all sent and received frames. No extra calls needed — just use the session directly.

---

Expand All @@ -96,35 +63,7 @@ class WiretapHttpConfig {
val WiretapKtorSsePlugin: ClientPlugin<Unit>
```

Placeholder plugin for SSE inspection. SSE connection tracking is handled by the `wiretapped()` extension on `ClientSSESession`.

---

## wiretapped() (SSE)

```kotlin
suspend fun ClientSSESession.wiretapped(): WiretapSseSession
```

Extension to wrap a Ktor SSE session for event logging. Creates a connection entry in Wiretap and returns a `WiretapSseSession` that intercepts incoming events.

---

## WiretapSseSession

```kotlin
interface WiretapSseSession {
val call: HttpClientCall
val incoming: Flow<ServerSentEvent>
}
```

### Properties

| Property | Type | Description |
|----------|------|-------------|
| `call` | `HttpClientCall` | The underlying HTTP call for this SSE connection |
| `incoming` | `Flow<ServerSentEvent>` | Incoming events with automatic logging |
SSE plugin that automatically wraps SSE sessions to log all incoming events. No extra calls needed — just use the session directly.

---

Expand Down
29 changes: 26 additions & 3 deletions docs/ktor/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ The `install` DSL is unchanged — only the config type name changed:

### SSE — new experimental API

SSE logging is new in RC10. Install the plugin and use `wiretapped()` on your SSE session:
SSE logging is new in RC10. Install the plugin and sessions are wrapped automatically:

```kotlin
val client = HttpClient {
Expand All @@ -46,9 +46,32 @@ val client = HttpClient {

@OptIn(ExperimentalWiretapSseApi::class)
client.sse("https://example.com/events") {
val session = this.wiretapped()
session.incoming.collect { event ->
incoming.collect { event ->
println("Event: ${event.data}")
}
}
```

## RC11 → RC12

### `wiretapped()` removed for WebSocket and SSE

`WiretapKtorWebSocketPlugin` and `WiretapKtorSsePlugin` now wrap sessions automatically. Remove all `wiretapped()` calls:

```diff
client.webSocket("wss://example.com/ws") {
- val session = this.wiretapped()
- session?.send(Frame.Text("Hello!"))
- for (frame in (session?.incoming ?: incoming)) { ... }
+ send(Frame.Text("Hello!"))
+ for (frame in incoming) { ... }
}

client.sse("https://example.com/events") {
- val session = this.wiretapped()
- session.incoming.collect { event -> ... }
+ incoming.collect { event -> ... }
}
```

The `wiretapped()` extension is now deprecated with `ERROR` level and simply returns `this`.
5 changes: 2 additions & 3 deletions docs/ktor/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,11 @@ val client = HttpClient {
}
```

Then wrap your SSE session with `wiretapped()`:
Sessions are wrapped automatically — just use the session directly:

```kotlin
client.sse("https://example.com/events") {
val session = this.wiretapped()
session.incoming.collect { event ->
incoming.collect { event ->
println("Event: ${event.event} — ${event.data}")
}
}
Expand Down
18 changes: 6 additions & 12 deletions docs/ktor/sse.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,19 @@ val client = HttpClient {
}
```

## Session Wrapping
## Automatic Session Wrapping

Wrap your SSE session with `wiretapped()` to log incoming events. This creates a connection entry in Wiretap and returns a logging wrapper that intercepts all incoming events:
`WiretapKtorSsePlugin` wraps SSE sessions automatically — no extra calls needed. Just use the session directly and all incoming events are logged:

```kotlin
@OptIn(ExperimentalWiretapSseApi::class)
client.sse("https://example.com/events") {
val session = this.wiretapped()

session.incoming.collect { event ->
incoming.collect { event ->
println("Event: ${event.event} — ${event.data}")
}
}
```

The `wiretapped()` extension is available on `ClientSSESession` — the standard Ktor SSE session type.

## WiretapSseSession API

| Property | Type | Description |
Expand All @@ -42,8 +38,8 @@ The `wiretapped()` extension is available on `ClientSSESession` — the standard

## How It Works

1. **`wiretapped()`** creates a connection entry via `SseLogManager` with status `Open`
2. Returns a `LoggingSseSession` that wraps the Ktor `ClientSSESession`
1. **`WiretapKtorSsePlugin`** automatically wraps SSE sessions and creates a connection entry via `SseLogManager` with status `Open`
2. The wrapped `LoggingSseSession` intercepts the Ktor `ClientSSESession`
3. Every incoming event is logged as it flows through the `incoming` flow
4. When the flow completes (server close, cancellation, or error), the connection status is updated to `Closed` or `Failed`

Expand Down Expand Up @@ -78,9 +74,7 @@ val client = HttpClient {

@OptIn(ExperimentalWiretapSseApi::class)
client.sse("https://api.example.com/stream") {
val session = this.wiretapped()

session.incoming.collect { event ->
incoming.collect { event ->
when (event.event) {
"message" -> handleMessage(event.data)
"heartbeat" -> { /* ignore */ }
Expand Down
14 changes: 6 additions & 8 deletions docs/ktor/websockets.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,17 @@ val client = HttpClient {
}
```

## Session Wrapping
## Automatic Session Wrapping

Wrap your WebSocket session with `wiretapped()` to log outgoing and incoming messages. Returns `null` if `WiretapKtorWebSocketPlugin` is not installed:
`WiretapKtorWebSocketPlugin` wraps WebSocket sessions automatically — no extra calls needed. All sent and received frames are logged:

```kotlin
client.webSocket("wss://echo.websocket.org") {
val session = this.wiretapped() // null if plugin not installed

// Send — automatically logged when session is available
session?.send(Frame.Text("Hello, server!"))
// Send — automatically logged
send(Frame.Text("Hello, server!"))

// Receive — automatically logged as frames are consumed
for (frame in (session?.incoming ?: incoming)) {
for (frame in incoming) {
when (frame) {
is Frame.Text -> println("Received: ${frame.readText()}")
is Frame.Binary -> println("Received ${frame.readBytes().size} bytes")
Expand All @@ -59,7 +57,7 @@ client.webSocket("wss://echo.websocket.org") {
1. **`WiretapKtorWebSocketPlugin`** hooks into `onResponse` for 101 Switching Protocols responses
2. Creates a `SocketEntry` via the orchestrator with status `Open`
3. Stores the socket ID on request attributes
4. **`wiretapped()`** creates a `WiretapWebSocketSession` that intercepts `send()` and auto-logs `incoming` frames
4. The plugin automatically wraps the session to intercept `send()` and auto-log `incoming` frames
5. Connection close/failure is detected automatically via job completion

## What Gets Logged
Expand Down
12 changes: 2 additions & 10 deletions wiretap-ktor-noop/api/jvm/wiretap-ktor-noop.api
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,14 @@ public abstract interface class dev/skymansandy/wiretap/plugin/sse/WiretapSseSes
}

public final class dev/skymansandy/wiretap/plugin/sse/WiretapWrapKt {
public static final fun wiretapped (Lio/ktor/client/plugins/sse/ClientSSESession;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public static final fun wiretapped (Lio/ktor/client/plugins/sse/ClientSSESession;)Lio/ktor/client/plugins/sse/ClientSSESession;
}

public final class dev/skymansandy/wiretap/plugin/ws/WiretapKtorWebSocketPluginKt {
public static final fun getWiretapKtorWebSocketPlugin ()Lio/ktor/client/plugins/api/ClientPlugin;
}

public abstract interface class dev/skymansandy/wiretap/plugin/ws/WiretapWebSocketSession : io/ktor/websocket/DefaultWebSocketSession {
public abstract fun getCall ()Lio/ktor/client/call/HttpClientCall;
}

public final class dev/skymansandy/wiretap/plugin/ws/WiretapWebSocketSession$DefaultImpls {
public static fun send (Ldev/skymansandy/wiretap/plugin/ws/WiretapWebSocketSession;Lio/ktor/websocket/Frame;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
}

public final class dev/skymansandy/wiretap/plugin/ws/WiretapWrapKt {
public static final fun wiretapped (Lio/ktor/client/plugins/websocket/DefaultClientWebSocketSession;)Ldev/skymansandy/wiretap/plugin/ws/WiretapWebSocketSession;
public static final fun wiretapped (Lio/ktor/client/plugins/websocket/DefaultClientWebSocketSession;)Lio/ktor/client/plugins/websocket/DefaultClientWebSocketSession;
}

Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import io.ktor.client.plugins.sse.ClientSSESession
import io.ktor.sse.ServerSentEvent
import kotlinx.coroutines.flow.Flow

/**
* No-op passthrough that just exposes the underlying session.
*/
@OptIn(ExperimentalWiretapSseApi::class)
@Deprecated(
message = "WiretapKtorSsePlugin now wraps sessions automatically. Use ClientSSESession directly.",
level = DeprecationLevel.ERROR,
)
@Suppress("DEPRECATION_ERROR")
internal class DelegatingSseSession(
private val delegate: ClientSSESession,
) : WiretapSseSession {
Expand Down
Loading
Loading