Soklet Logo

Core Concepts

Request Lifecycle

Soklet provides two lifecycle hooks:

They're useful for tasks like:

  • Logging requests and responses
  • Performing authentication and authorization
  • Modifying requests/responses before downstream processing occurs
  • Wrapping downstream code in a database transaction
  • Monitoring unexpected errors that occur during internal processing (see Event Logging)

This is similar to the Jakarta EE Servlet Filter concept, but provides additional functionality beyond "wrap the whole request".


Request Interceptor

Request Wrapping

Wraps around the whole "outside" of an entire request-handling flow. The "inside" of the flow is everything from Resource Method execution to writing response bytes to the client. For a more fine-grained approach, see Request Intercepting.

Wrapping happens before Soklet resolves which Resource Method should handle the request. This means you can rewrite the HTTP method or path by returning a modified request via the consumer and Soklet will route using the wrapped request. You must call requestProcessor.accept(...) exactly once before returning; otherwise Soklet logs an error and returns a 500 response.

Wrapping a request is useful when you'd like to store off request-scoped information that needs to be made easily accessible to other parts of the system - for example, a request's Locale and ZoneId.

To implement, a useful scoping mechanism is Java's ScopedValue<T> (Java 25+) or ThreadLocal<T>. The former is demonstrated below.

// Special scoped value so anyone can access the current Locale.
// For Java < 25, use ThreadLocal instead
public static final ScopedValue<Locale> CURRENT_LOCALE;

// Spin up the ScopedValue (or ThreadLocal)
static {
  CURRENT_LOCALE = ScopedValue.newInstance();
}

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).requestInterceptor(new RequestInterceptor() {
  @Override
  public void wrapRequest(
    @NonNull ServerType serverType,
    @NonNull Request request,
    @NonNull Consumer<Request> requestProcessor
  ) {
    // Make the locale accessible by other code during this request...
    Locale locale = request.getLocales().get(0);

    // ...by binding it to a ScopedValue (or ThreadLocal).
    ScopedValue.where(CURRENT_LOCALE, locale).run(() -> {
      // You must call this so downstream processing can proceed
      requestProcessor.accept(request);
    });
  }
}).build();

Then, elsewhere in your code while a request is being processed:

class ExampleService {
  void accessCurrentLocale() {
    // You now have access to the Locale bound to the logical scope
    // (or Thread) without having to pass it down the call stack
    Locale locale = CURRENT_LOCALE.orElse(Locale.getDefault());
  }
}

References:

Request Intercepting

Conceptually, when a request comes in, Soklet will:

  1. Invoke the appropriate Resource Method to acquire a response
  2. Send the response over the wire to the client

Request Intercepting provides programmatic control over those two steps.

Example use cases are:

  • Wrapping Resource Methods with a database transaction (e.g. to ensure atomicity)
  • Customizing responses prior to sending over the wire

For a more coarse-grained approach, see Request Wrapping.

You must call responseWriter.accept(...) exactly once before returning; otherwise Soklet logs an error and returns a 500 response.

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).requestInterceptor(new RequestInterceptor() {
  @Override
  public void interceptRequest(
    @NonNull ServerType serverType,
    @NonNull Request request,
    @Nullable ResourceMethod resourceMethod,
    @NonNull Function<Request, MarshaledResponse> responseGenerator,
    @NonNull Consumer<MarshaledResponse> responseWriter
  ) {
    // Here's where you might start a DB transaction
    MyDatabase.INSTANCE.beginTransaction();

    // Step 1: Invoke the Resource Method and acquire its response
    MarshaledResponse response = responseGenerator.apply(request);

    // Commit the DB transaction before marshaling/sending the response
    // to reduce contention by keeping "open" time short
    MyDatabase.INSTANCE.commitTransaction();

    // You might also perform systemwide response adjustments here.
    // For example, set a special header via mutable copy
    response = response.copy().headers((mutableHeaders) -> {
      mutableHeaders.put("X-Powered-By", Set.of("Soklet"));
    }).finish();

    // Step 2: Send the finalized response over the wire
    responseWriter.accept(response);
  }
}).build();

References:

Lifecycle Observer

LifecycleObserver is the right hook for read-only request, stream, SSE, and MCP observation: tracing, audit events, structured logging, and cleanup notifications. For aggregate counters, gauges, and histograms, prefer MetricsCollector instead.

Configure one observer with SokletConfig.Builder::lifecycleObserver, or configure several observers in registration order with SokletConfig.Builder::lifecycleObservers. Calling lifecycleObserver(null) or lifecycleObservers(null) clears the observer list.

SokletConfig config = SokletConfig.withHttpServer(HttpServer.fromPort(8080))
  .lifecycleObservers(List.of(
    new AuditLifecycleObserver(),
    new TracingLifecycleObserver()
  ))
  .build();

Soklet Start/Stop

Execute code immediately before and after a Soklet instance starts up and shuts down.

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).lifecycleObserver(new LifecycleObserver() {
  @Override
  public void willStartSoklet(@NonNull Soklet soklet) {
    // Perform startup tasks required prior to Soklet launch
    MyPayrollSystem.INSTANCE.startLengthyWarmupProcess();
  }

  @Override
  public void didStartSoklet(@NonNull Soklet soklet) {
    // Soklet has fully started.
    //@NonNull HttpServer (and optionally SSE server) are running
    System.out.println("Soklet started.");
  }

  @Override
  public void didFailToStartSoklet(
    @NonNull Soklet soklet,
    @NonNull Throwable throwable
  ) {
    System.err.println("Soklet failed to start.");
    throwable.printStackTrace();
  }

  @Override
  public void willStopSoklet(@NonNull Soklet soklet) {
    // Perform shutdown tasks required prior to Soklet teardown
    MyPayrollSystem.INSTANCE.destroy();
  }

  @Override
  public void didStopSoklet(@NonNull Soklet soklet) {
    // Soklet has fully shut down
    System.out.println("Soklet stopped.");
  }

  @Override
  public void didFailToStopSoklet(
    @NonNull Soklet soklet,
    @NonNull Throwable throwable
  ) {
    System.err.println("Soklet failed to stop cleanly.");
    throwable.printStackTrace();
  }
}).build();

References:

HTTP Server Start/Stop

Execute code immediately before and after the Soklet-managed HttpServer starts up and shuts down.

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).lifecycleObserver(new LifecycleObserver() {
  @Override
  public void willStartHttpServer(@NonNull HttpServer httpServer) {
    // Perform startup tasks required prior to server launch
    MyPayrollSystem.INSTANCE.startLengthyWarmupProcess();
  }

  @Override
  public void didStartHttpServer(@NonNull HttpServer httpServer) {
    // HTTP server has fully started up and is listening
    System.out.println("HTTP server started.");
  }

  @Override
  public void didFailToStartHttpServer(
    @NonNull HttpServer httpServer,
    @NonNull Throwable throwable
  ) {
    System.err.println("HTTP server failed to start.");
    throwable.printStackTrace();
  }

  @Override
  public void willStopHttpServer(@NonNull HttpServer httpServer) {
    // Perform shutdown tasks required prior to server teardown
    MyPayrollSystem.INSTANCE.destroy();
  }

  @Override
  public void didStopHttpServer(@NonNull HttpServer httpServer) {
    // HTTP server has fully shut down
    System.out.println("HTTP server stopped.");
  }

  @Override
  public void didFailToStopHttpServer(
    @NonNull HttpServer httpServer,
    @NonNull Throwable throwable
  ) {
    System.err.println("HTTP server failed to stop cleanly.");
    throwable.printStackTrace();
  }
}).build();

References:

Connection Acceptance

These methods fire when Soklet is about to accept a TCP connection, after it accepts one, or when it fails to accept a connection attempt. Each callback provides a ServerType, a best-effort remote address, and (for failures) a ConnectionRejectionReason plus an optional exception.

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).lifecycleObserver(new LifecycleObserver() {
  @Override
  public void willAcceptConnection(
    @NonNull ServerType serverType,
    @Nullable InetSocketAddress remoteAddress
  ) {
    System.out.printf("Incoming %s connection from %s\n", serverType, remoteAddress);
  }

  @Override
  public void didAcceptConnection(
    @NonNull ServerType serverType,
    @Nullable InetSocketAddress remoteAddress
  ) {
    System.out.printf("Accepted %s connection from %s\n", serverType, remoteAddress);
  }

  @Override
  public void didFailToAcceptConnection(
    @NonNull ServerType serverType,
    @Nullable InetSocketAddress remoteAddress,
    @NonNull ConnectionRejectionReason reason,
    @Nullable Throwable throwable
  ) {
    System.out.printf("Failed to accept %s connection from %s (%s)\n", serverType, remoteAddress, reason);
  }
}).build();

References:

Request Acceptance

These hooks fire as Soklet accepts requests for application-level handling and reads/parses them into Request instances.

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).lifecycleObserver(new LifecycleObserver() {
  @Override
  public void willAcceptRequest(
    @NonNull ServerType serverType,
    @Nullable InetSocketAddress remoteAddress,
    @Nullable String requestTarget
  ) {
    System.out.printf("About to accept request: %s%n", requestTarget);
  }

  @Override
  public void didAcceptRequest(
    @NonNull ServerType serverType,
    @Nullable InetSocketAddress remoteAddress,
    @Nullable String requestTarget
  ) {
    System.out.printf("Accepted request: %s%n", requestTarget);
  }

  @Override
  public void didFailToAcceptRequest(
    @NonNull ServerType serverType,
    @Nullable InetSocketAddress remoteAddress,
    @Nullable String requestTarget,
    @NonNull RequestRejectionReason reason,
    @Nullable Throwable throwable
  ) {
    System.err.printf("Failed to accept request (%s): %s%n", reason, requestTarget);
  }

  @Override
  public void willReadRequest(
    @NonNull ServerType serverType,
    @Nullable InetSocketAddress remoteAddress,
    @Nullable String requestTarget
  ) {
    System.out.printf("About to read request: %s%n", requestTarget);
  }

  @Override
  public void didReadRequest(
    @NonNull ServerType serverType,
    @Nullable InetSocketAddress remoteAddress,
    @Nullable String requestTarget
  ) {
    System.out.printf("Read request: %s%n", requestTarget);
  }

  @Override
  public void didFailToReadRequest(
    @NonNull ServerType serverType,
    @Nullable InetSocketAddress remoteAddress,
    @Nullable String requestTarget,
    @NonNull RequestReadFailureReason reason,
    @Nullable Throwable throwable
  ) {
    System.err.printf("Failed to read request (%s): %s%n", reason, requestTarget);
  }
}).build();

References:

Request Handling

These methods are fired at the very start of request processing and the very end, respectively.

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).lifecycleObserver(new LifecycleObserver() {
  @Override
  public void didStartRequestHandling(
    @NonNull ServerType serverType,
    @NonNull Request request,
    @Nullable ResourceMethod resourceMethod
  ) {
    System.out.printf("Received request: %s\n", request);

    // If there was no resourceMethod matching the request, expect a 404
    if(resourceMethod != null)
      System.out.printf("Request to be handled by: %s\n", resourceMethod);
    else
      System.out.println("This will be a 404.");
  }

  @Override
  public void didFinishRequestHandling(
    @NonNull ServerType serverType,
    @NonNull Request request,
    @Nullable ResourceMethod resourceMethod,
    @NonNull MarshaledResponse marshaledResponse,
    @NonNull Duration processingDuration,
    @NonNull List<Throwable> throwables
  ) {
    // We have access to a few things here...
    // * marshaledResponse is what was ultimately sent
    //    over the wire
    // * processingDuration is how long everything took,
    //    including sending the response to the client
    // * throwables is the ordered list of exceptions
    //    thrown during execution (if any)
    double millis = processingDuration.toNanos() / 1_000_000.0;
    System.out.printf("Entire request took %dms\n", millis);
  }
}).build();

References:

Response Writing

Monitor the response writing process - sending bytes over the wire. You can respond to successful writes or failed writes (e.g. unexpected client disconnect).

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).lifecycleObserver(new LifecycleObserver() {
  @Override
  public void willWriteResponse(
    @NonNull ServerType serverType,
    @NonNull Request request,
    @Nullable ResourceMethod resourceMethod,
    @NonNull MarshaledResponse marshaledResponse
  ) {
    // Access to marshaledResponse here lets us see exactly
    // how much response body data will be written
    Long bodyLength = marshaledResponse.getBodyLength();
    System.out.printf("About to start writing response with " +
      "a %d-byte body...\n", bodyLength);
  }

  @Override
  public void didWriteResponse(
    @NonNull ServerType serverType,
    @NonNull Request request,
    @Nullable ResourceMethod resourceMethod,
    @NonNull MarshaledResponse marshaledResponse,
    @NonNull Duration responseWriteDuration
  ) {
    double millis = responseWriteDuration.toNanos() / 1_000_000.0;
    System.out.printf("Took %dms to write response\n", millis);
  }

  @Override
  public void didFailToWriteResponse(
    @NonNull ServerType serverType,
    @NonNull Request request,
    @Nullable ResourceMethod resourceMethod,
    @NonNull MarshaledResponse marshaledResponse,
    @NonNull Duration responseWriteDuration,
    @NonNull Throwable throwable
  ) {
    double millis = responseWriteDuration.toNanos() / 1_000_000.0;
    System.out.printf("Response write failed after %dms\n", millis);

    // Use this to monitor trends in unexpected client disconnects
    System.err.println("Exception occurred while writing response");
    throwable.printStackTrace();
  }

  @Override
  public void willTerminateResponseStream(
    @NonNull StreamingResponseHandle streamingResponse,
    @NonNull StreamTermination termination
  ) {
    System.out.printf("Streaming response terminating: %s%n",
      termination.getReason());
  }

  @Override
  public void didTerminateResponseStream(
    @NonNull StreamingResponseHandle streamingResponse,
    @NonNull StreamTermination termination
  ) {
    if (termination.getReason() == StreamTerminationReason.COMPLETED) {
      System.out.println("Streaming response completed normally");
    } else {
      System.err.printf("Streaming response ended after %dms: %s%n",
        termination.getDuration().toMillis(), termination.getReason());
    }
  }
}).build();

For non-streaming responses, didWriteResponse and didFailToWriteResponse cover the response write handoff. For StreamingResponseBody, willTerminateResponseStream and didTerminateResponseStream are invoked when the stream completes, is canceled, or fails. These hooks record stream duration, distinguish client disconnects from producer failures, and clean up application-side accounting. Standard HTTP stream termination callbacks are normally back-to-back because there is no broadcaster or session registry cleanup phase between them.

For request correlation, prefer the request-bearing objects already passed through the lifecycle API. StreamingResponseHandle, SseConnection, and McpSseStream expose their originating Request via getRequest(), and LogEvent exposes it via getRequest() when available. Use Request::getId() for local log and metric correlation. For distributed trace correlation, these same request-bearing objects can reach Request::getTraceContext; see Trace Context.

References:

Stream Termination

Soklet uses the same StreamTermination and StreamTerminationReason model for streaming HTTP responses, dedicated SSE connections, and MCP SSE streams. The callback remains protocol-specific, but the termination payload always includes a reason, duration, and optional cause.

ReasonCommon sources
COMPLETEDStreaming HTTP response producer finished normally
CLIENT_DISCONNECTEDHTTP streaming client, SSE client, or MCP stream client closed the connection
SERVER_STOPPINGThe owning server stopped while the stream was active
PROTOCOL_UNSUPPORTEDA request protocol cannot support the stream, such as HTTP/1.0 for chunked streaming
RESPONSE_TIMEOUTStreaming HTTP response exceeded its total timeout
RESPONSE_IDLE_TIMEOUTStreaming HTTP response producer exceeded its idle timeout
APPLICATION_CANCELEDProducer code intentionally threw StreamingResponseCanceledException
BACKPRESSURESSE connection was closed because its write queue reached capacity
SESSION_TERMINATEDMCP session termination closed its active SSE stream
WRITE_FAILEDStream write or transport processing failed
PRODUCER_FAILEDStreaming HTTP response producer threw unexpectedly
INTERNAL_ERRORUnexpected internal error
SIMULATOR_LIMIT_EXCEEDEDSimulator refused to materialize more streaming response bytes
UNKNOWNStream ended and no more specific reason was available

Server-Sent Events

If your application configures a SseServer, additional lifecycle hooks are available for SSE server and connection activity.

SSE Server Start/Stop

SokletConfig config = SokletConfig.withSseServer(
  SseServer.fromPort(8081)
).lifecycleObserver(new LifecycleObserver() {
  @Override
  public void willStartSseServer(
    @NonNull SseServer sseServer
  ) {
    System.out.println("SSE server starting.");
  }

  @Override
  public void didStartSseServer(
    @NonNull SseServer sseServer
  ) {
    System.out.println("SSE server started.");
  }

  @Override
  public void didFailToStartSseServer(
    @NonNull SseServer sseServer,
    @NonNull Throwable throwable
  ) {
    System.err.println("SSE server failed to start.");
    throwable.printStackTrace();
  }

  @Override
  public void willStopSseServer(
    @NonNull SseServer sseServer
  ) {
    System.out.println("SSE server stopping.");
  }

  @Override
  public void didStopSseServer(
    @NonNull SseServer sseServer
  ) {
    System.out.println("SSE server stopped.");
  }

  @Override
  public void didFailToStopSseServer(
    @NonNull SseServer sseServer,
    @NonNull Throwable throwable
  ) {
    System.err.println("SSE server failed to stop cleanly.");
    throwable.printStackTrace();
  }
}).build();

References:

SSE Connections

SokletConfig config = SokletConfig.withSseServer(
  SseServer.fromPort(8081)
).lifecycleObserver(new LifecycleObserver() {
  @Override
  public void willEstablishSseConnection(
    @NonNull Request request,
    @NonNull ResourceMethod resourceMethod
  ) {
    System.out.printf("Establishing SSE connection for %s\n", request);
  }

  @Override
  public void didEstablishSseConnection(
    @NonNull SseConnection sseConnection
  ) {
    System.out.printf("SSE connection established: %s\n", sseConnection);
  }

  @Override
  public void didFailToEstablishSseConnection(
    @NonNull Request request,
    @Nullable ResourceMethod resourceMethod,
    @NonNull SseConnection.HandshakeFailureReason reason,
    @Nullable Throwable throwable
  ) {
    System.err.println("SSE connection establishment failed.");
    if (throwable != null)
      throwable.printStackTrace();
  }

  @Override
  public void willTerminateSseConnection(
    @NonNull SseConnection sseConnection,
    @NonNull StreamTermination termination
  ) {
    System.out.printf("SSE connection terminating (%s)\n", termination.getReason());
  }

  @Override
  public void didTerminateSseConnection(
    @NonNull SseConnection sseConnection,
    @NonNull StreamTermination termination
  ) {
    System.out.printf("SSE connection terminated after %dms (%s)\n",
      termination.getDuration().toMillis(), termination.getReason());
  }
}).build();

References:

SSE Event and Comment Writes

SokletConfig config = SokletConfig.withSseServer(
  SseServer.fromPort(8081)
).lifecycleObserver(new LifecycleObserver() {
  @Override
  public void willWriteSseEvent(
    @NonNull SseConnection sseConnection,
    @NonNull SseEvent sseEvent
  ) {
    System.out.printf("Writing SSE event %s\n", sseEvent.getEvent().orElse("message"));
  }

  @Override
  public void didWriteSseEvent(
    @NonNull SseConnection sseConnection,
    @NonNull SseEvent sseEvent,
    @NonNull Duration writeDuration
  ) {
    System.out.printf("SSE event write took %dms\n", writeDuration.toMillis());
  }

  @Override
  public void didFailToWriteSseEvent(
    @NonNull SseConnection sseConnection,
    @NonNull SseEvent sseEvent,
    @NonNull Duration writeDuration,
    @NonNull Throwable throwable
  ) {
    System.err.println("SSE event write failed.");
    throwable.printStackTrace();
  }

  @Override
  public void willWriteSseComment(
    @NonNull SseConnection sseConnection,
    @NonNull SseComment sseComment
  ) {
    System.out.printf("Writing SSE comment (%s): %s\n",
      sseComment.getCommentType(),
      sseComment.getComment().orElse("<heartbeat>"));
  }

  @Override
  public void didWriteSseComment(
    @NonNull SseConnection sseConnection,
    @NonNull SseComment sseComment,
    @NonNull Duration writeDuration
  ) {
    System.out.printf("SSE comment write took %dms\n", writeDuration.toMillis());
  }

  @Override
  public void didFailToWriteSseComment(
    @NonNull SseConnection sseConnection,
    @NonNull SseComment sseComment,
    @NonNull Duration writeDuration,
    @NonNull Throwable throwable
  ) {
    System.err.println("SSE comment write failed.");
    throwable.printStackTrace();
  }
}).build();

References:

Model Context Protocol

If your application configures an McpServer, additional lifecycle hooks are available for MCP server startup and shutdown, session creation and termination, JSON-RPC request handling, and session-bound GET stream activity.

The ordinary connection and request hooks described earlier still fire for MCP transport traffic. The methods below add MCP-specific context such as endpoint class, session ID, JSON-RPC method name, request outcome, and stream termination reason.

MCP Server Start/Stop

SokletConfig config = SokletConfig.withMcpServer(
  McpServer.withPort(8082)
    .handlerResolver(McpHandlerResolver.fromClasspathIntrospection())
    .build()
).lifecycleObserver(new LifecycleObserver() {
  @Override
  public void willStartMcpServer(@NonNull McpServer mcpServer) {
    System.out.println("MCP server starting.");
  }

  @Override
  public void didStartMcpServer(@NonNull McpServer mcpServer) {
    System.out.println("MCP server started.");
  }

  @Override
  public void didFailToStartMcpServer(
    @NonNull McpServer mcpServer,
    @NonNull Throwable throwable
  ) {
    System.err.println("MCP server failed to start.");
    throwable.printStackTrace();
  }

  @Override
  public void willStopMcpServer(@NonNull McpServer mcpServer) {
    System.out.println("MCP server stopping.");
  }

  @Override
  public void didStopMcpServer(@NonNull McpServer mcpServer) {
    System.out.println("MCP server stopped.");
  }

  @Override
  public void didFailToStopMcpServer(
    @NonNull McpServer mcpServer,
    @NonNull Throwable throwable
  ) {
    System.err.println("MCP server failed to stop cleanly.");
    throwable.printStackTrace();
  }
}).build();

References:

MCP Sessions and JSON-RPC Requests

These hooks are useful when you want to observe MCP at the protocol level rather than the raw HTTP transport level. For example, didStartMcpRequestHandling and didFinishMcpRequestHandling expose the JSON-RPC method name (initialize, tools/call, and so on), the resolved endpoint class, the session ID if one exists, the final McpRequestOutcome, and any returned McpJsonRpcError.

SokletConfig config = SokletConfig.withMcpServer(
  McpServer.withPort(8082)
    .handlerResolver(McpHandlerResolver.fromClasspathIntrospection())
    .build()
).lifecycleObserver(new LifecycleObserver() {
  @Override
  public void didCreateMcpSession(
    @NonNull Request request,
    @NonNull Class<? extends McpEndpoint> endpointClass,
    @NonNull String sessionId
  ) {
    System.out.printf("Created MCP session %s for %s%n",
      sessionId, endpointClass.getSimpleName());
  }

  @Override
  public void didStartMcpRequestHandling(
    @NonNull Request request,
    @NonNull Class<? extends McpEndpoint> endpointClass,
    @Nullable String sessionId,
    @NonNull String jsonRpcMethod,
    @Nullable McpJsonRpcRequestId jsonRpcRequestId
  ) {
    System.out.printf("Starting MCP %s on %s (session=%s, requestId=%s)%n",
      jsonRpcMethod, endpointClass.getSimpleName(), sessionId, jsonRpcRequestId);
  }

  @Override
  public void didFinishMcpRequestHandling(
    @NonNull Request request,
    @NonNull Class<? extends McpEndpoint> endpointClass,
    @Nullable String sessionId,
    @NonNull String jsonRpcMethod,
    @Nullable McpJsonRpcRequestId jsonRpcRequestId,
    @NonNull McpRequestOutcome requestOutcome,
    @Nullable McpJsonRpcError jsonRpcError,
    @NonNull Duration duration,
    @NonNull List<@NonNull Throwable> throwables
  ) {
    System.out.printf("Finished MCP %s with %s in %dms%n",
      jsonRpcMethod, requestOutcome, duration.toMillis());

    if (jsonRpcError != null)
      System.out.printf("JSON-RPC error: %s%n", jsonRpcError);
  }

  @Override
  public void didTerminateMcpSession(
    @NonNull Class<? extends McpEndpoint> endpointClass,
    @NonNull String sessionId,
    @NonNull Duration sessionDuration,
    @NonNull McpSessionTerminationReason terminationReason,
    @Nullable Throwable throwable
  ) {
    System.out.printf("Terminated MCP session %s after %dms (%s)%n",
      sessionId, sessionDuration.toMillis(), terminationReason);

    if (throwable != null)
      throwable.printStackTrace();
  }
}).build();

References:

MCP Streams

MCP GET requests establish session-bound SSE streams for server-originated messages. Soklet exposes lifecycle hooks for when those streams are established and when they terminate. Unlike the dedicated SSE APIs above, MCP currently does not expose separate per-message write callbacks here.

SokletConfig config = SokletConfig.withMcpServer(
  McpServer.withPort(8082)
    .handlerResolver(McpHandlerResolver.fromClasspathIntrospection())
    .build()
).lifecycleObserver(new LifecycleObserver() {
  @Override
  public void didEstablishMcpSseStream(@NonNull McpSseStream stream) {
    System.out.printf("Established MCP stream for session %s on %s%n",
      stream.getSessionId(), stream.getEndpointClass().getSimpleName());
  }

  @Override
  public void willTerminateMcpSseStream(
    @NonNull McpSseStream stream,
    @NonNull StreamTermination termination
  ) {
    System.out.printf("MCP stream for session %s terminating (%s)%n",
      stream.getSessionId(), termination.getReason());
  }

  @Override
  public void didTerminateMcpSseStream(
    @NonNull McpSseStream stream,
    @NonNull StreamTermination termination
  ) {
    System.out.printf("MCP stream for session %s terminated after %dms (%s)%n",
      stream.getSessionId(), termination.getDuration().toMillis(), termination.getReason());

    termination.getCause().ifPresent(Throwable::printStackTrace);
  }
}).build();

References:

Event Logging

Soklet provides insight into unexpected errors that occur during internal processing that are not otherwise surfaced via LogEvent objects provided to LifecycleObserver::didReceiveLogEvent.

For example, you might want to specially monitor scenarios in which your ResponseMarshaler::forThrowable failed (that is, you attempted to write an error response for an exception that bubbled out, but that attempt threw an exception, forcing Soklet to write its own failsafe response) - you could do this by observing events with type LogEventType.RESPONSE_MARSHALER_FOR_THROWABLE_FAILED. Low-level HTTP, SSE, and MCP transport failures such as response write-idle timeouts, write timeouts, accept-loop failures, event-loop task failures, and selection-key failures are emitted as LogEventType.SERVER_TRANSPORT_FAILURE.

Your LifecycleObserver can listen for LogEvents like this:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).lifecycleObserver(new LifecycleObserver() {
  // This example uses SLF4J. See https://www.slf4j.org
  private final Logger logger =
    LoggerFactory.getLogger("com.soklet.example.LifecycleObserver");

  @Override
  public void didReceiveLogEvent(@NonNull LogEvent logEvent) {
    // These properties are available in LogEvent
    LogEventType logEventType = logEvent.getLogEventType();
    String message = logEvent.getMessage();
    Optional<Throwable> throwable = logEvent.getThrowable();
    Optional<Request> request = logEvent.getRequest();
    Optional<ResourceMethod> resourceMethod = logEvent.getResourceMethod();
    Optional<MarshaledResponse> marshaledResponse = logEvent.getMarshaledResponse();

    // Log the message however you like
    logger.warn(message, throwable.orElse(null));
  }
}).build();

References:

Previous
Static Files