diff --git a/http-responsiveness-server/Examples/ping-benchmarks/collect_benchmarks.sh b/http-responsiveness-server/Examples/ping-benchmarks/collect_benchmarks.sh new file mode 100755 index 0000000..29d28d8 --- /dev/null +++ b/http-responsiveness-server/Examples/ping-benchmarks/collect_benchmarks.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# +# collect_benchmarks.sh +# +# Description: +# Reads “Request handled in XX ms” lines from a SwiftNIO HTTPResponsivenessServer +# log file (produced when started with --collect-benchmarks) and computes +# p0, p25, p50, p75, p90, p99 and p100 statistics. +# +# Called from run_benchmarks.sh +# +# + +set -euo pipefail + +if [ $# -ne 3 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +LOGFILE="$1" +ENDPOINT="$2" +SAMPLES="$3" + +# extract exactly the first N timings +mapfile -t times < <( + grep -a "Request handled in" "$LOGFILE" \ + | head -n "$SAMPLES" \ + | sed -E 's/.*Request handled in ([0-9]+(\.[0-9]+)?) ms/\1/' +) + +if [ "${#times[@]}" -lt "$SAMPLES" ]; then + echo "ERROR: only found ${#times[@]} samples in $LOGFILE" >&2 + exit 1 +fi + +# compute percentiles in Python +python3 - "$ENDPOINT" "${times[@]}" << 'PYCODE' +import sys, numpy as np + +# parse float timings from command-line args (skip the first entry which is '-') +data = [float(x) for x in sys.argv[2:]] +arr = np.array(data) + +# compute percentiles +pcts = [ np.percentile(arr, p) for p in (0, 25, 50, 75, 90, 99, 100) ] +samples = arr.size +metric = sys.argv[1] + +# nice Unicode table +print("╒═══════════════════════╤══════════╤══════════╤══════════╤══════════╤══════════╤══════════╤══════════╤══════════╕") +print("│ Metric │ p0 │ p25 │ p50 │ p75 │ p90 │ p99 │ p100 │ Samples │") +print("╞═══════════════════════╪══════════╪══════════╪══════════╪══════════╪══════════╪══════════╪══════════╪══════════╡") +print(f"│ {metric:20s} │ " + + " │ ".join(f"{v:7.6f}" for v in pcts) + + f" │ {samples:7d} │") +print("╘═══════════════════════╧══════════╧══════════╧══════════╧══════════╧══════════╧══════════╧══════════╧══════════╛") +PYCODE diff --git a/http-responsiveness-server/Examples/ping-benchmarks/run_benchmarks.sh b/http-responsiveness-server/Examples/ping-benchmarks/run_benchmarks.sh new file mode 100755 index 0000000..dfc2626 --- /dev/null +++ b/http-responsiveness-server/Examples/ping-benchmarks/run_benchmarks.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# +# run_benchmarks.sh +# +# Description: +# Sends HTTP GETs to a running HTTPResponsivenessServer (started with --collect-benchmarks) +# then invokes collect_benchmarks.sh on its log. +# +# Prerequisites: +# 1. Build your server: +# swift build -c release +# +# 2. Start it in the background, redirecting stdout+stderr to a log: +# +# stdbuf -oL -eL .build/release/HTTPResponsivenessServer \ +# --host 127.0.0.1 \ +# --insecure-port 8080 \ +# --collect-benchmarks \ +# &> server.log +# +# Usage: +# In the root directory of this App +# Examples/ping-benchmarks/run_benchmarks.sh \ +# --host 127.0.0.1 \ +# --port 8080 \ +# --endpoint /ping \ +# --samples 100 \ +# --logfile server.log +# + +set -euo pipefail + +# --- parse args --- +while [[ $# -gt 0 ]]; do + case $1 in + --host) HOST="$2"; shift 2 ;; + --port) PORT="$2"; shift 2 ;; + --endpoint) ENDPOINT="$2"; shift 2 ;; + --samples) SAMPLES="$2"; shift 2 ;; + --logfile) LOGFILE="$2"; shift 2 ;; + *) + echo "Unknown argument: $1" >&2 + exit 1 + ;; + esac +done + +# --- sanity check logfile --- +if [[ ! -f "$LOGFILE" ]]; then + cat <&2 +ERROR: logfile '$LOGFILE' not found. +Make sure you started the server with: + + stdbuf -oL -eL .build/release/HTTPResponsivenessServer --host $HOST --insecure-port $PORT --collect-benchmarks &> $LOGFILE + +EOF + exit 1 +fi + +# --- clear out any old measurements --- +: > "$LOGFILE" + +echo "→ Sending $SAMPLES requests to http://$HOST:$PORT$ENDPOINT" +for i in $(seq 1 "$SAMPLES"); do + curl -s "http://$HOST:$PORT$ENDPOINT" > /dev/null +done + +# give the server a moment to log +sleep 0.1 + +# --- wait for exactly SAMPLES log entries --- +echo "→ Waiting up to 5s for $SAMPLES entries in '$LOGFILE' …" +deadline=$((SECONDS + 5)) +while (( SECONDS < deadline )); do + count=$(grep -c "Request handled in" "$LOGFILE" || true) + if (( count >= SAMPLES )); then + break + fi + sleep 0.1 +done + +# final check +count=$(grep -c "Request handled in" "$LOGFILE" || true) +if (( count < SAMPLES )); then + echo "ERROR: only found $count samples in '$LOGFILE' (wanted $SAMPLES)" >&2 + exit 1 +fi + +echo "→ Collected $count samples, now computing percentiles…" +Examples/ping-benchmarks/collect_benchmarks.sh "$LOGFILE" "$ENDPOINT" "$SAMPLES" diff --git a/http-responsiveness-server/Sources/HTTPResponsivenessServer/main.swift b/http-responsiveness-server/Sources/HTTPResponsivenessServer/main.swift index 8bfffee..142184c 100644 --- a/http-responsiveness-server/Sources/HTTPResponsivenessServer/main.swift +++ b/http-responsiveness-server/Sources/HTTPResponsivenessServer/main.swift @@ -13,6 +13,7 @@ //===----------------------------------------------------------------------===// import ArgumentParser import ExtrasJSON +import Foundation import NIOCore import NIOHTTP1 import NIOHTTP2 @@ -28,6 +29,81 @@ enum ChannelInitializeError: Error { case unrecognizedPort(Int?) } +// MARK: Performance Measurement Handler + +/// Logs the time between request head and request end. +final class PerformanceMeasurementHandler: ChannelInboundHandler { + typealias InboundIn = HTTPServerRequestPart + private var startTime: DispatchTime? + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let part = unwrapInboundIn(data) + switch part { + case .head: + startTime = DispatchTime.now() + context.fireChannelRead(data) + case .body: + context.fireChannelRead(data) + case .end: + if let start = startTime { + let elapsedMs = Double(DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds) / 1_000_000 + print("Request handled in \(String(format: "%.2f", elapsedMs)) ms") + } + context.fireChannelRead(data) + } + } +} + +/// Simple `/ping` endpoint that immediately responds “pong” — and swallows both head and end. +final class PingHandler: ChannelInboundHandler { + // HTTP *requests* coming in… + typealias InboundIn = HTTPServerRequestPart + // HTTP *responses* going out. + typealias OutboundOut = HTTPServerResponsePart + + // Track whether we’re currently in the middle of a ping request: + private var handlingPing = false + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let part = unwrapInboundIn(data) + + switch part { + case .head(let head) where head.uri == "/ping": + // Mark that we’ve started handling ping: + handlingPing = true + + // Build and send a 200 OK / "pong" + var resHead = HTTPResponseHead(version: head.version, status: .ok) + resHead.headers.add(name: "Content-Length", value: "4") + resHead.headers.add(name: "Content-Type", value: "text/plain") + context.write(wrapOutboundOut(.head(resHead)), promise: nil) + + var buf = context.channel.allocator.buffer(capacity: 4) + buf.writeString("pong") + context.write(wrapOutboundOut(.body(.byteBuffer(buf))), promise: nil) + + // And flush the end of response: + context.writeAndFlush(wrapOutboundOut(.end(nil)), promise: nil) + + // **Do not forward** the request head downstream. + return + + case .body where handlingPing: + // ignore any body for ping + return + + case .end where handlingPing: + // ping is done + handlingPing = false + return + + default: + // everything else: pass through normally + context.fireChannelRead(data) + } + } +} + func configureCommonHTTPTypesServerPipeline( _ channel: Channel, _ configurator: @Sendable @escaping (Channel) -> EventLoopFuture @@ -64,7 +140,8 @@ func channelInitializer( channel: Channel, tls: ([Int], NIOSSLContext, ByteBuffer)?, insecure: ([Int], ByteBuffer)?, - isNIOTS: Bool = false + isNIOTS: Bool = false, + collectBenchmarks: Bool = false ) -> EventLoopFuture { // Handle TLS case var port = channel.localAddress?.port @@ -83,6 +160,9 @@ func channelInitializer( } return configureCommonHTTPTypesServerPipeline(channel) { channel in channel.eventLoop.makeCompletedFuture { + if collectBenchmarks { + try channel.pipeline.syncOperations.addHandler(PerformanceMeasurementHandler()) + } try channel.pipeline.syncOperations.addHandler( SimpleResponsivenessRequestMux(responsivenessConfigBuffer: config) ) @@ -93,6 +173,11 @@ func channelInitializer( // Handle insecure case if let (ports, config) = insecure, let port, ports.contains(port) { return channel.pipeline.configureHTTPServerPipeline().flatMapThrowing { + // If benchmarking is enabled, install timer and ping handlers first: + if collectBenchmarks { + try channel.pipeline.syncOperations.addHandler(PerformanceMeasurementHandler()) + try channel.pipeline.syncOperations.addHandler(PingHandler()) + } let mux = SimpleResponsivenessRequestMux(responsivenessConfigBuffer: config) return try channel.pipeline.syncOperations.addHandlers([ HTTP1ToHTTPServerCodec(secure: false), @@ -146,6 +231,9 @@ private struct HTTPResponsivenessServer: ParsableCommand { @Option(help: "override how many threads to use") var threads: Int? = nil + @Flag(help: "Enable per-request performance measurements") + var collectBenchmarks: Bool = false + func run() throws { if port == nil && insecurePort == nil { throw RunError.inputError("must provide either port or insecurePort") @@ -199,7 +287,8 @@ private struct HTTPResponsivenessServer: ParsableCommand { channel: channel, tls: tls, insecure: insecure, - isNIOTS: useNetwork + isNIOTS: useNetwork, + collectBenchmarks: collectBenchmarks ) }) @@ -233,7 +322,8 @@ private struct HTTPResponsivenessServer: ParsableCommand { channelInitializer( channel: channel, tls: tls, - insecure: insecure + insecure: insecure, + collectBenchmarks: collectBenchmarks ) }) @@ -261,6 +351,7 @@ private struct HTTPResponsivenessServer: ParsableCommand { print("Listening on https://\(host):\(port!)") return out } + let insecureChannel = try insecureChannelBootstrap.map { let out = try $0.wait() print("Listening on http://\(host):\(insecurePort!)")