When monitoring or debugging your web applications, it’s often useful to know the size of the requests and responses that are being sent and received. In this article, we’ll discuss how to count incoming traffic lengths in the Play Framework using Scala.
Suppose, we have a Play Action
that accepts json data:
class TrafficDemoController @Inject()(val controllerComponents: ControllerComponents)
extends BaseController with Logging {
implicit val ec: ExecutionContext = controllerComponents.executionContext
def simpleJsonAction: Action[JsValue] = Action(parse.json) { request =>
logger.info(s"Incoming body: ${ request.body }")
Ok("Ok")
}
}
Native approach: using Content-Length header
A simple way to count the size of an incoming request is to read the “Content-Length” header:
def countIncomingTrafficNaive: Action[JsValue] = Action(parse.json) { request =>
val incomingLength = request.headers.get("Content-Length").getOrElse(0)
logger.info(s"Incoming body ($incomingLength): ${ request.body }")
Ok("Ok")
}
Let’s test it:
curl -X POST -H "Content-Type: application/json" -d "{ \"a\": \"b\" }" http://localhost:9000/incomingNaive
App logs show the following:
2023-08-07 21:10:58 INFO controllers.TrafficDemoController Incoming body (12): {"a":"b"}
However, while this approach is straightforward, it won’t work for chunked requests, where the “Content-Length” header is not present.
Let’s try to test in with chunked http request. For this we’ll use the following simple Python script:
import requests
# Break data into chunks of size `chunk_size`
def chunked_data(data, chunk_size=10):
for i in range(0, len(data), chunk_size):
yield data[i:i+chunk_size]
url = "http://localhost:9000/incomingNaive"
json_data = """{ "a": "b", "c": "d" }"""
# Send the request
headers = {'Transfer-Encoding': 'chunked','Content-Type': 'application/json'}
requests.post(url, data=chunked_data(json_data), headers=headers)
This time the application logs show the following:
2023-08-07 21:21:00 INFO controllers.TrafficDemoController Incoming body (0): {"a":"b","c":"d"}
Advanced Approach: handling chunked requests
To properly handle chunked HTTP requests, we must tally the length of each individual chunk and then aggregate these lengths to obtain the total size. For this we can create a custom BodyParser
that incrementally calculates the length as each chunk is received, without the need to store the entire request in memory.
Here is an example of BodyParser that counts body size:
import play.api.libs.streams.Accumulator
import play.api.mvc.{Action, BaseController, BodyParser, ControllerComponents}
/// ...
implicit val ec: ExecutionContext = controllerComponents.executionContext
val countingBodyParser: BodyParser[Int] = BodyParser("countingBodyParser") { _ =>
Accumulator {
Sink
.fold(0) { (total, bytes: ByteString) =>
val chunkSize = bytes.length
val newTotal = total + chunkSize
logger.info(s"Received chunk of length: $chunkSize bytes, total so far: $newTotal bytes")
newTotal
}
.mapMaterializedValue { _.map(Right(_)) }
}
}
It creates a custom Play Accumulator by providing it Akka Streams Sink
, that accumulates the total chunks size.
Checking how it works now:
def countIncomingTrafficBetter: Action[Int] = Action(countingBodyParser) { request =>
val incomingLength = request.body
logger.info(s"Incoming length: $incomingLength bytes")
Ok("Ok")
}
It seems to work properly, now the logs show the following:
2023-08-07 21:54:49 INFO controllers.TrafficDemoController Received chunk of length: 10 bytes, total so far: 10 bytes
2023-08-07 21:54:49 INFO controllers.TrafficDemoController Received chunk of length: 10 bytes, total so far: 20 bytes
2023-08-07 21:54:49 INFO controllers.TrafficDemoController Received chunk of length: 2 bytes, total so far: 22 bytes
2023-08-07 21:54:49 INFO controllers.TrafficDemoController Incoming length: 22 bytes
But of course, there’s not much use of such parser, because it completely ignores the incoming body, leaving us with only body size it hands. Fortunately, akka-streams
library provides a way to combine multiple Sinks into one, so we can come with a custom parser that is a result of combining two another parsers:
def combinedParser[A, B](aParser: BodyParser[A], bParser: BodyParser[B]): BodyParser[(A, B)] =
BodyParser(s"CombinedParser: $aParser + $bParser") { request =>
val sinkA = aParser(request).toSink
val sinkB = bParser(request).toSink
val sinkCombined = Flow[ByteString]
.alsoToMat(sinkA)(Keep.right)
.toMat(sinkB)(Keep.both)
.mapMaterializedValue { case (aFuture, bFuture) =>
// combine two Future[Either[_, _]] values into one:
for {
aEither <- aFuture
bEither <- bFuture
} yield for {
a <- aEither
b <- bEither
} yield (a, b)
}
Accumulator(sinkCombined)
}
We can make use of this parser as following:
def countIncomingTrafficFinal: Action[(Int, JsValue)] =
Action(combinedParser(countingBodyParser, parse.json)) { request =>
val (incomingLength, jsonBody) = request.body
logger.info(s"Incoming body($incomingLength): $jsonBody")
Ok("Ok")
}
Trying to call it:
2023-08-07 22:13:49 INFO controllers.TrafficDemoController Received chunk of length: 10 bytes, total so far: 10 bytes
2023-08-07 22:13:49 INFO controllers.TrafficDemoController Received chunk of length: 10 bytes, total so far: 20 bytes
2023-08-07 22:13:49 INFO controllers.TrafficDemoController Received chunk of length: 2 bytes, total so far: 22 bytes
2023-08-07 22:13:49 INFO controllers.TrafficDemoController Incoming body(22): {"a":"b","c":"d"}
As we can see, now both parsers are working and the app printed both request body length and json content.
You can find the complete code for this tutorial in our GitHub repository.
Conclusion
This technique provides a flexible and powerful way to handle both standard and chunked requests, offering valuable insights into the behavior and performance of your Play Framework applications. Whether you’re building large-scale systems or simply keen to understand the data flow in your application, this method can be a key part of your toolkit.