├── .gitignore ├── LICENSE ├── README.md ├── build.sbt ├── changelog.txt ├── project └── build.properties ├── publish.sbt └── src ├── main └── scala │ ├── ElasticGatlingAppender.scala │ ├── GatlingElasticPublisher.scala │ ├── GatlingLogParser.scala │ ├── GatlingLogSettings.scala │ ├── Settings.scala │ └── WsLog.scala └── test ├── resources ├── simpleGet.txt ├── simplePost.txt ├── withoutResponse.txt ├── ws.txt └── wsWithoutCheck.txt └── scala ├── HttpLogTests.scala └── TestHelpers.scala /.gitignore: -------------------------------------------------------------------------------- 1 | .bsp 2 | .idea 3 | dist/* 4 | target/ 5 | lib_managed/ 6 | src_managed/ 7 | project/boot/ 8 | project/plugins/project/ 9 | .history 10 | .cache 11 | .lib/ 12 | 13 | *.class 14 | *.log 15 | 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Pavel Bairov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gatling Elasticsearch Logs [![Maven Central](https://maven-badges.herokuapp.com/maven-central/io.github.amerousful/gatling-elasticsearch-logs/badge.svg?style=flat)](https://maven-badges.herokuapp.com/maven-central/io.github.amerousful/gatling-elasticsearch-logs/) 2 | 3 | Logger which parses raw Gatling logs and sends them to the Elasticsearch. 4 | 5 | ## Motivation 6 | 7 | By default, Gatling writes logs to the console, which is inconvenient for analysing, collecting and storing information. 8 | In fact, metrics don't contain the details of errors, they only have request status OK or KO. 9 | When a metric occurs with an error it's impossible to figure out what happened: a check failed? got 404? or it was 502? etc. 10 | Also, if you run load tests in the distributed mode, it will store your logs in separate injectors. 11 | This logger allows getting all useful information so that it will be possible to correlate with your metrics. 12 | 13 | To recap, the Logger is solving two main problems: 14 | 15 | - Distributed metrics sending and storing them 16 | - You can build a Graph with errors details for correlation by metrics 17 | 18 | ## Install 19 | 20 | ### Maven: 21 | 22 | Add to your `pom.xml` 23 | 24 | ```xml 25 | 26 | io.github.amerousful 27 | gatling-elasticsearch-logs 28 | 1.6.2 29 | 30 | ``` 31 | 32 | ### SBT 33 | 34 | Add to your `build.sbt` 35 | 36 | ```scala 37 | libraryDependencies += "io.github.amerousful" % "gatling-elasticsearch-logs" % "1.6.2" 38 | ``` 39 | 40 | ## How to configure `logback.xml` 41 | 42 | I provide minimal configuration, but you can add additional things what you need 43 | 44 | ```xml 45 | 46 | 47 | 48 | 49 | 50 | ${logLevel} 51 | ACCEPT 52 | DENY 53 | 54 | http://${elkUrl}/_bulk 55 | gatling-%date{yyyy.MM.dd} 56 | gatling 57 | true 58 | index 59 | 60 | 61 |
62 | Content-Type 63 | application/json 64 |
65 |
66 |
67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 |
80 | ``` 81 | *** 82 | This logger based on https://github.com/agido-malter/logback-elasticsearch-appender which is directly responsible for sending to Elasticsearch. 83 | There you can find some addition and useful options related with sending 84 | *** 85 | 86 | Pay attention on two variables in config: 87 | 88 | `elkUrl` - URL of your Elasticsearch 89 | 90 | `logLevel` - log's level. **DEBUG** to log all failed HTTP requests. **TRACE** to log all HTTP requests 91 | 92 | Example how to pass the above variable during the run load test: 93 | 94 | ```shell 95 | mvn gatling:test -DelkUrl=%URL%:%PORT% -DlogLevel=%LEVEL% 96 | ``` 97 | 98 | ## Flags: 99 | 100 | ### Parse Session 101 | 102 | Logger can also parse Session attributes and send them to Elasticsearch. 103 | As an example, your test might contain some entity id: `userId`, `serverId`, etc. It's useful for filtering data. 104 | Here is what you need to add to the appender: 105 | 106 | ```xml 107 | 108 | 109 | userId;serverId 110 | 111 | 112 | ``` 113 | 114 | In my case, I add `simulation` name to Session. 115 | Although metrics writes by **simulation** name, logs contain only **class** name, and you can't match them. 116 | During several simultaneously running tests, you will get mixed logs which we can filter by `simulation` name. 117 | 118 | Example how to override `scenario` method and write `simulation` to Session: 119 | ```scala 120 | 121 | class Example extends Simulation { 122 | 123 | override def scenario(name: String): ScenarioBuilder = { 124 | import io.gatling.commons.util.StringHelper._ 125 | 126 | 127 | val simulationName = RichString(this.getClass.getSimpleName).clean 128 | 129 | io.gatling.core.Predef.scenario(name) 130 | .exec(_.set("simulation", simulationName)) 131 | } 132 | 133 | val scn: ScenarioBuilder = scenario("Example scenario") 134 | .exec(request) 135 | ... 136 | } 137 | ``` 138 | 139 | *** 140 | 141 | Exclude logs from failed resources in silent mode (`NoopStatsProcessor`): 142 | ```xml 143 | true 144 | ``` 145 | 146 | *** 147 | 148 | ## How it works 149 | 150 | The principle of works is to parse logs and then separate them by necessary fields. Currently, the Logger supports: 151 | 152 | - HTTP 153 | - WebSocket 154 | - SessionHookBuilder 155 | 156 | 157 | Example of how the Logger parsing a raw log by fields: 158 | 159 | ### __HTTP__: 160 | ### Raw log: 161 | ```text 162 | >>>>>>>>>>>>>>>>>>>>>>>>>> 163 | Request: 164 | get request: OK 165 | ========================= 166 | Session: 167 | Session(Example scenario,1,Map(gatling.http.ssl.sslContexts -> io.gatling.http.util.SslContexts@434d148, gatling.http.cache.dns -> io.gatling.http.resolver.ShufflingNameResolver@105cb8b8, gatling.http.cache.baseUrl -> https://httpbin.org, identifier -> ),OK,List(),io.gatling.core.protocol.ProtocolComponentsRegistry$$Lambda$529/0x0000000800604840@1e8ad005,io.netty.channel.nio.NioEventLoop@60723d6a) 168 | ========================= 169 | HTTP request: 170 | GET https://httpbin.org/get 171 | headers: 172 | accept: */* 173 | host: httpbin.org 174 | ========================= 175 | HTTP response: 176 | status: 177 | 200 OK 178 | headers: 179 | Date: Wed, 25 Aug 2021 08:31:38 GMT 180 | Content-Type: application/json 181 | Connection: keep-alive 182 | Server: gunicorn/19.9.0 183 | Access-Control-Allow-Origin: * 184 | Access-Control-Allow-Credentials: true 185 | content-length: 223 186 | 187 | body: 188 | { 189 | "args": {}, 190 | "headers": { 191 | "Accept": "*/*", 192 | "Host": "httpbin.org", 193 | "X-Amzn-Trace-Id": "Root=1-6125ffea-3e25d40360dd3cc425c1a26f" 194 | }, 195 | "origin": "2.2.2.2", 196 | "url": "https://httpbin.org/get" 197 | } 198 | 199 | <<<<<<<<<<<<<<<<<<<<<<<<< 200 | ``` 201 | 202 | ### Result: 203 | 204 | | Field name | Value | 205 | |:-----------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 206 | | request_name | get request | 207 | | message | OK | 208 | | session | Session(Example scenario,1,Map(gatling.http.ssl.sslContexts -> io.gatling.http.util.SslContexts@434d148, gatling.http.cache.dns -> io.gatling.http.resolver.ShufflingNameResolver@105cb8b8, gatling.http.cache.baseUrl -> https://httpbin.org, identifier -> ),OK,List(),io.gatling.core.protocol.ProtocolComponentsRegistry$$Lambda$529/0x0000000800604840@1e8ad005,io.netty.channel.nio.NioEventLoop@60723d6a) | 209 | | method | GET | 210 | | request_body | %empty% | 211 | | request_headers | accept: \*/\*
host: httpbin.org | 212 | | url | https://httpbin.org/get | 213 | | status_code | 200 | 214 | | response_headers | Date: Wed, 25 Aug 2021 08:31:38 GMT
Content-Type: application/json
Connection: keep-alive
Server: gunicorn/19.9.0
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
content-length: 223 | 215 | | response_body | {
 "args": {},
 "headers": {
  "Accept": "\*/\*",
  "Host": "httpbin.org",
  "X-Amzn-Trace-Id": "Root=1-6125ffea-3e25d40360dd3cc425c1a26f"
 },
 "origin": "2.2.2.2",
 "url": "https://httpbin.org/get"
} | 216 | | protocol | http | 217 | | scenario | Example scenario | 218 | | userId | 1 | 219 | 220 | *** 221 | ### __WebSocket__: 222 | ### Raw log: 223 | ```text 224 | >>>>>>>>>>>>>>>>>>>>>>>>>> 225 | Request: 226 | Send Auth: KO Check timeout 227 | ========================= 228 | Session: 229 | Session(Example scenario,1,HashMap(gatling.http.cache.wsBaseUrl -> wss://mm-websocket.site.com, ...) 230 | ========================= 231 | WebSocket check: 232 | Wait DELETED 233 | ========================= 234 | WebSocket request: 235 | {"auth":{"userId":123}} 236 | ========================= 237 | WebSocket received messages: 238 | 17:50:41.612 [0] -> {"type":"EXPORT_PROCESS","status":"DISPATCHED"} 239 | 17:50:41.741 [1] -> {"type":"EXPORT_PROCESS","status":"RECEIVED"} 240 | 17:50:41.830 [2] -> {"type":"EXPORT_PROCESS","status":"RECEIVED"} 241 | 17:50:42.073 [3] -> {"type":"EXPORT_PROCESS","status":"RECEIVED"} 242 | 17:50:44.209 [4] -> {"type":"EXPORT_PROCESS","status":"RECEIVED"} 243 | 17:50:44.379 [5] -> {"type":"EXPORT_PROCESS","status":"RECEIVED"} 244 | 17:50:44.437 [6] -> {"type":"EXPORT_PROCESS","status":"COMPLETED"} 245 | <<<<<<<<<<<<<<<<<<<<<<<<< 246 | ``` 247 | ### Result: 248 | 249 | | Field name | Value | 250 | |:------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 251 | | request_name | Send Auth: KO Check timeout | 252 | | message | KO Check timeout | 253 | | session | Session(Example scenario,1,HashMap(gatling.http.cache.wsBaseUrl -> wss://mm-websocket.site.com, ...) | 254 | | request_body | {"auth":{"userId":123}} | 255 | | url | wss://mm-websocket.site.com | 256 | | response_body | 17:50:41.612 [0] -> {"type":"EXPORT_PROCESS","status":"DISPATCHED"}
17:50:41.741 [1] -> {"type":"EXPORT_PROCESS","status":"RECEIVED"}
17:50:41.830 [2] -> {"type":"EXPORT_PROCESS","status":"RECEIVED"}
17:50:42.073 [3] -> {"type":"EXPORT_PROCESS","status":"RECEIVED"}
17:50:44.209 [4] -> {"type":"EXPORT_PROCESS","status":"RECEIVED"}
17:50:44.379 [5] -> {"type":"EXPORT_PROCESS","status":"RECEIVED"}
17:50:44.437 [6] -> {"type":"EXPORT_PROCESS","status":"COMPLETED"} | 257 | | protocol | ws | 258 | | check_name | Wait DELETED | 259 | | userId | 1 260 | 261 | ## Grafana 262 | 263 | Integration Elasticsearch with Grafana. You can find addition information here 264 | 265 | - https://grafana.com/docs/grafana/latest/datasources/elasticsearch/ 266 | 267 | Example of a graph which based on these logs 268 | ![Grafana example](https://user-images.githubusercontent.com/22199881/187403299-90f42a4b-360c-48b4-8bb9-7b6cd9152201.png) 269 | 270 | 271 | ## Schema 272 | ![Untitled Diagram drawiosasasa](https://github.com/user-attachments/assets/0917fb66-cabd-4b5b-85a6-681a1f64f912) 273 | 274 | 275 | 276 | 277 | 278 | ## Contributing 279 | 280 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 281 | 282 | ## License 283 | 284 | [MIT](https://choosealicense.com/licenses/mit/) 285 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | name := "gatling-elasticsearch-logs" 2 | 3 | version := "1.6.2" 4 | 5 | scalaVersion := "2.13.8" 6 | 7 | libraryDependencies ++= Seq( 8 | "com.agido" % "logback-elasticsearch-appender" % "3.0.11", 9 | "ch.qos.logback" % "logback-classic" % "1.4.7", 10 | "ch.qos.logback" % "logback-core" % "1.4.7", 11 | "org.scalatest" %% "scalatest" % "3.2.15" % "test" 12 | ) 13 | -------------------------------------------------------------------------------- /changelog.txt: -------------------------------------------------------------------------------- 1 | Changelog 1.6.2 (14-06-2024) 2 | ---------------------------- 3 | * Update Logback Elasticsearch Appender ^3.0.11 4 | 5 | Changelog 1.6.1 (26-09-2023) 6 | ---------------------------- 7 | * Fix regex for WS request payload (newline) 8 | 9 | Changelog 1.6 (14-09-2023) 10 | ---------------------------- 11 | * Technical release for 1.5.3 12 | * Update logback version 13 | 14 | Changelog 1.5.3 (07-09-2023) 15 | ---------------------------- 16 | * Replace outdated logback-elasticsearch-appender with the new one 17 | 18 | Changelog 1.5.2 (22-02-2023) 19 | ---------------------------- 20 | * Fix WS doesn't extract session attribute 21 | 22 | Changelog 1.5.1 (21-02-2023) 23 | ---------------------------- 24 | * Fix WS for message without check 25 | 26 | Changelog 1.5 (17-02-2023) 27 | ---------------------------- 28 | * Full support for Websocket 29 | 30 | Changelog 1.4 (12-10-2022) 31 | ---------------------------- 32 | * Parse `SessionHookBuilder` 33 | 34 | Changelog 1.3 (11-10-2022) 35 | ---------------------------- 36 | * Refactoring parse condition and remove flag 'otherMessages' 37 | 38 | Changelog 1.2 (10-10-2022) 39 | ---------------------------- 40 | * Flag for send all logs 41 | 42 | Changelog 1.1 (07-10-2022) 43 | ---------------------------- 44 | * Flag for filter logs from resource's requests (failed in silent mode) -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version = 1.9.4 -------------------------------------------------------------------------------- /publish.sbt: -------------------------------------------------------------------------------- 1 | ThisBuild / organization := "io.github.amerousful" 2 | ThisBuild / scalaVersion := "2.13.8" 3 | ThisBuild / versionScheme := Some("pvp") 4 | 5 | ThisBuild / scmInfo := Some( 6 | ScmInfo( 7 | url("https://github.com/Amerousful/gatling-elasticsearch-logs"), 8 | "scm:git:git://github.com/Amerousful/gatling-elasticsearch-logs.git" 9 | ) 10 | ) 11 | ThisBuild / developers := List( 12 | Developer( 13 | id = "Amerousful", 14 | name = "Pavel Bairov", 15 | email = "amerousful@gmail.com", 16 | url = url("https://github.com/Amerousful") 17 | ) 18 | ) 19 | 20 | ThisBuild / description := "Send Gatling's logs to Elasticsearch" 21 | ThisBuild / licenses := List("The MIT License (MIT)" -> new URL("https://opensource.org/licenses/MIT")) 22 | ThisBuild / homepage := Some(url("https://github.com/Amerousful/gatling-elasticsearch-logs")) 23 | 24 | ThisBuild / crossPaths := false 25 | 26 | ThisBuild / pomIncludeRepository := { _ => false } 27 | ThisBuild / publishTo := { 28 | val nexus = "https://oss.sonatype.org/" 29 | if (isSnapshot.value) Some("snapshots" at nexus + "content/repositories/snapshots") 30 | else Some("releases" at nexus + "service/local/staging/deploy/maven2") 31 | } 32 | ThisBuild / publishMavenStyle := true 33 | -------------------------------------------------------------------------------- /src/main/scala/ElasticGatlingAppender.scala: -------------------------------------------------------------------------------- 1 | import ch.qos.logback.classic.spi.ILoggingEvent 2 | import com.agido.logback.elasticsearch.AbstractElasticsearchAppender 3 | 4 | import scala.util.control.Breaks.break 5 | 6 | class ElasticGatlingAppender extends AbstractElasticsearchAppender[ILoggingEvent] with Settings { 7 | 8 | override def buildElasticsearchPublisher(): GatlingElasticPublisher = { 9 | new GatlingElasticPublisher(getContext, errorReporter, settings, elasticsearchProperties, headers, gatlingLogSettings) 10 | } 11 | 12 | override def appendInternal(eventObject: ILoggingEvent): Unit = { 13 | val targetLogger = eventObject.getLoggerName 14 | 15 | val loggerName = settings.getLoggerName 16 | if (loggerName != null && loggerName == targetLogger) break() 17 | 18 | val errorLoggerName = settings.getErrorLoggerName 19 | if (errorLoggerName != null && errorLoggerName == targetLogger) break() 20 | 21 | eventObject.prepareForDeferredProcessing() 22 | if (settings.isIncludeCallerData) eventObject.getCallerData 23 | 24 | publishEvent(eventObject) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/scala/GatlingElasticPublisher.scala: -------------------------------------------------------------------------------- 1 | import ch.qos.logback.classic.Level.{DEBUG, TRACE} 2 | import ch.qos.logback.classic.spi.ILoggingEvent 3 | import ch.qos.logback.core.Context 4 | import com.agido.logback.elasticsearch.util.ErrorReporter 5 | import com.fasterxml.jackson.core.JsonGenerator 6 | import com.agido.logback.elasticsearch.AbstractElasticsearchPublisher 7 | import com.agido.logback.elasticsearch.AbstractElasticsearchPublisher.getTimestamp 8 | import com.agido.logback.elasticsearch.config.{ElasticsearchProperties, HttpRequestHeaders, Property, Settings} 9 | import com.agido.logback.elasticsearch.util.{AbstractPropertyAndEncoder, ClassicPropertyAndEncoder, ErrorReporter} 10 | 11 | import scala.jdk.CollectionConverters.CollectionHasAsScala 12 | 13 | class GatlingElasticPublisher(context: Context, 14 | errorReporter: ErrorReporter, 15 | settings: Settings, 16 | properties: ElasticsearchProperties, 17 | headers: HttpRequestHeaders, 18 | gatlingLogSettings: GatlingLogSettings) 19 | extends AbstractElasticsearchPublisher[ILoggingEvent](context, errorReporter, settings, properties, headers) { 20 | 21 | override def buildPropertyAndEncoder(context: Context, property: Property): AbstractPropertyAndEncoder[ILoggingEvent] = { 22 | new ClassicPropertyAndEncoder(property, context) 23 | } 24 | 25 | override def serializeCommonFields(gen: JsonGenerator, event: ILoggingEvent): Unit = { 26 | gen.writeObjectField("@timestamp", getTimestamp(event.getTimeStamp)) 27 | 28 | val extractSessionAttributes = gatlingLogSettings.extractSessionAttributes 29 | 30 | val message = formatMessageBySize(event.getFormattedMessage) 31 | 32 | val wsEvent = event.getLoggerName.contains("io.gatling.http.action.ws") 33 | val httpEvent = if (gatlingLogSettings.excludeResources.getOrElse(false)) 34 | event.getLoggerName.equals("io.gatling.http.engine.response.DefaultStatsProcessor") 35 | else event.getLoggerName.contains("io.gatling.http.engine.response") 36 | val sessionHookEvent = event.getLoggerName.contains("io.gatling.core.action.builder.SessionHookBuilder") 37 | 38 | val levelCondition = event.getLevel == DEBUG || event.getLevel == TRACE 39 | 40 | (httpEvent, wsEvent, levelCondition, sessionHookEvent) match { 41 | case (true, false, true, false) => GatlingLogParser.httpFields(gen, message, extractSessionAttributes) 42 | case (false, true, true, false) => GatlingLogParser.wsFields(gen, message, extractSessionAttributes) 43 | case (false, false, false, true) => GatlingLogParser.sessionFields(gen, message, extractSessionAttributes) 44 | case _ => gen.writeObjectField("message", message) 45 | } 46 | 47 | if (settings.isIncludeMdc) { 48 | for (entry <- event.getMDCPropertyMap.entrySet.asScala) { 49 | gen.writeObjectField(entry.getKey, entry.getValue) 50 | } 51 | } 52 | 53 | } 54 | 55 | def formatMessageBySize(message: String): String = { 56 | if (settings.getMaxMessageSize > 0 && message.length > settings.getMaxMessageSize) 57 | message.substring(0, settings.getMaxMessageSize) + ".." 58 | else message 59 | } 60 | 61 | } 62 | -------------------------------------------------------------------------------- /src/main/scala/GatlingLogParser.scala: -------------------------------------------------------------------------------- 1 | import com.fasterxml.jackson.core.JsonGenerator 2 | 3 | object GatlingLogParser { 4 | 5 | private val separator = "=========================" 6 | 7 | def httpFields(gen: JsonGenerator, fullMessage: String, extractSessionAttributes: Option[String] = None): Unit = { 8 | 9 | // Gatling since 3.4.2 write two logs instead one. 10 | // First log only with message. 11 | if (!fullMessage.contains(separator)) { 12 | gen.writeObjectField("message", fullMessage) 13 | } 14 | else { 15 | val partOfMessage = fullMessage.split(separator) 16 | val infoPart = partOfMessage(0) 17 | val sessionPart = partOfMessage(1) 18 | val requestPart = partOfMessage(2) 19 | val responsePart = partOfMessage(3) 20 | 21 | val firstPattern = """Request:\n(.*):\s(.*)""".r.unanchored 22 | val secondPattern = """Session:\n(.*)""".r.unanchored 23 | val methodAndUrlPattern = """HTTP request:\n(\w+)\s(.*)""".r.unanchored 24 | val requestBodyPattern = """body:([\s\S]*)\n""".r.unanchored 25 | val responseBodyPattern = """body:\n([\s\S]*)\n""".r.unanchored 26 | val requestHeadersPattern = { 27 | if (requestPart.contains("byteArraysBody")) """headers:\n\t([\s\S]*)\nbyteArraysBody""".r.unanchored 28 | else if (requestPart.contains("body")) """headers:\n\t([\s\S]*)\nbody""".r.unanchored 29 | else """headers:\n\t([\s\S]*)\n""".r.unanchored 30 | } 31 | val responseHeadersPattern = """headers:\n\t([\s\S]*?)\n\n""".r.unanchored 32 | 33 | val statusPattern = """status:\n\t(\d{3})""".r.unanchored 34 | val sessionNamePattern = """Session\((.*?),""".r.unanchored 35 | 36 | 37 | val firstPattern(requestName, messageRaw) = infoPart 38 | val message = messageRaw.trim 39 | val secondPattern(session) = sessionPart 40 | val methodAndUrlPattern(method, url) = requestPart 41 | 42 | val requestBody = requestPart match { 43 | case requestBodyPattern(result) => result 44 | case _ => "%empty%" 45 | } 46 | val responseBody = responsePart match { 47 | case responseBodyPattern(result) => result 48 | case _ => "%empty%" 49 | } 50 | val requestHeadersPattern(requestHeadersRaw) = requestPart 51 | val requestHeaders = requestHeadersRaw.replaceAll("\t", "") 52 | val statusCode = responsePart match { 53 | case statusPattern(result) => result 54 | case _ => "%empty%" 55 | } 56 | val responseHeadersRaw = responsePart match { 57 | case responseHeadersPattern(result) => result 58 | case _ => "%empty%" 59 | } 60 | val responseHeaders = responseHeadersRaw.replaceAll("\t", "") 61 | 62 | val sessionNamePattern(scenario) = session 63 | val userIdPattern = s"""$scenario,(\\d+),""".r.unanchored 64 | val userIdPattern(userId) = session 65 | 66 | extractSessionAttributes match { 67 | case Some(attributes) if attributes.nonEmpty => 68 | val extract = attributes.split(";") 69 | extract.foreach { key => 70 | val regex = raw"""$key\s->\s([^,)]*)""".r.unanchored 71 | 72 | session match { 73 | case regex(value) => gen.writeObjectField(key, value) 74 | case _ => 75 | } 76 | } 77 | case _ => 78 | } 79 | 80 | gen.writeObjectField("message", message) 81 | gen.writeObjectField("method", method) 82 | gen.writeObjectField("url", url) 83 | gen.writeObjectField("request_body", requestBody) 84 | gen.writeObjectField("request_headers", requestHeaders) 85 | gen.writeObjectField("status_code", statusCode) 86 | gen.writeObjectField("response_body", responseBody) 87 | gen.writeObjectField("response_headers", responseHeaders) 88 | gen.writeObjectField("session", session) 89 | gen.writeObjectField("scenario", scenario) 90 | gen.writeObjectField("userId", userId) 91 | gen.writeObjectField("request_name", requestName) 92 | gen.writeObjectField("protocol", "http") 93 | } 94 | } 95 | 96 | def wsFields(gen: JsonGenerator, fullMessage: String, extractSessionAttributes: Option[String] = None): Unit = { 97 | 98 | // Gatling since 3.4.2 write two logs instead one. 99 | // First log only with message. 100 | if (!fullMessage.contains(separator)) { 101 | gen.writeObjectField("message", fullMessage) 102 | } 103 | else { 104 | val partOfMessage = fullMessage.split(separator) 105 | 106 | val firstPattern = """Request:\n(.*):\s(.*)""".r.unanchored 107 | val secondPattern = """Session:\n(.*)""".r.unanchored 108 | val sessionNamePattern = """Session\((.*?),""".r.unanchored 109 | val checkNamePattern = """WebSocket check:\n(.*)""".r.unanchored 110 | val requestPattern = """WebSocket request:\n([\s\S]*)\n""".r.unanchored 111 | val receivedPattern = """WebSocket received messages:\n([\s\S]*)\n""".r.unanchored 112 | 113 | val wsLog: WsLog = partOfMessage.length match { 114 | // for request without check and response body 115 | case 4 => WsLog(partOfMessage(0), partOfMessage(1), None, partOfMessage(2), None) 116 | case 5 => WsLog(partOfMessage(0), partOfMessage(1), Some(partOfMessage(2)), partOfMessage(3), Some(partOfMessage(4))) 117 | case _ => throw new RuntimeException(s"Failed to parse WS log: ${fullMessage}") 118 | } 119 | 120 | val secondPattern(session) = wsLog.sessionPart 121 | 122 | val checkName = wsLog.checkPart.getOrElse("") match { 123 | case checkNamePattern(result) => result 124 | case _ => "%empty%" 125 | } 126 | 127 | val responseBody = wsLog.responsePart.getOrElse("") match { 128 | case receivedPattern(result) => result 129 | case _ => "%empty%" 130 | } 131 | 132 | val requestPattern(requestBody) = wsLog.requestPart 133 | val firstPattern(requestName, messageRaw) = wsLog.infoPart 134 | val message = messageRaw.trim 135 | 136 | val urlRegex = """gatling\.http\.cache\.wsBaseUrl\s->\s([^,)]*)""".r.unanchored 137 | val urlRegex(url) = session 138 | 139 | val sessionNamePattern(scenario) = session 140 | val userIdPattern = s"""$scenario,(\\d+),""".r.unanchored 141 | val userIdPattern(userId) = session 142 | 143 | extractSessionAttributes match { 144 | case Some(attributes) if attributes.nonEmpty => 145 | val extract = attributes.split(";") 146 | extract.foreach { key => 147 | val regex = raw"""$key\s->\s([^,)]*)""".r.unanchored 148 | 149 | session match { 150 | case regex(value) => gen.writeObjectField(key, value) 151 | case _ => 152 | } 153 | } 154 | case _ => 155 | } 156 | 157 | gen.writeObjectField("message", message) 158 | gen.writeObjectField("url", url) 159 | gen.writeObjectField("request_body", requestBody) 160 | gen.writeObjectField("response_body", responseBody) 161 | gen.writeObjectField("check_name", checkName) 162 | gen.writeObjectField("session", session) 163 | gen.writeObjectField("scenario", scenario) 164 | gen.writeObjectField("userId", userId) 165 | gen.writeObjectField("request_name", requestName) 166 | gen.writeObjectField("protocol", "ws") 167 | } 168 | } 169 | 170 | def sessionFields(gen: JsonGenerator, fullMessage: String, extractSessionAttributes: Option[String] = None): Unit = { 171 | val messagePattern = """(.*?)\sSession.*\)(.*)""".r.unanchored 172 | val sessionPattern = """(Session.*\))""".r.unanchored 173 | val scenarioNamePattern = """Session\((.*?),""".r.unanchored 174 | 175 | val sessionPattern(session) = fullMessage 176 | val scenarioNamePattern(scenario) = session 177 | 178 | val userIdPattern = s"""$scenario,(\\d+),""".r.unanchored 179 | val userIdPattern(userId) = session 180 | 181 | extractSessionAttributes match { 182 | case Some(attributes) if attributes.nonEmpty => 183 | val extract = attributes.split(";") 184 | extract.foreach { key => 185 | val regex = raw"""$key\s->\s([^,)]*)""".r.unanchored 186 | 187 | session match { 188 | case regex(value) => gen.writeObjectField(key, value) 189 | case _ => 190 | } 191 | } 192 | case _ => 193 | } 194 | 195 | val s"${messagePattern(firstPart, secondPart)}" = fullMessage 196 | val message = firstPart + secondPart 197 | 198 | gen.writeObjectField("message", message) 199 | gen.writeObjectField("session", session) 200 | gen.writeObjectField("scenario", scenario) 201 | gen.writeObjectField("userId", userId) 202 | } 203 | 204 | } 205 | -------------------------------------------------------------------------------- /src/main/scala/GatlingLogSettings.scala: -------------------------------------------------------------------------------- 1 | class GatlingLogSettings { 2 | 3 | var extractSessionAttributes: Option[String] = None 4 | var excludeResources: Option[Boolean] = None 5 | 6 | } 7 | -------------------------------------------------------------------------------- /src/main/scala/Settings.scala: -------------------------------------------------------------------------------- 1 | trait Settings { 2 | 3 | val gatlingLogSettings: GatlingLogSettings = new GatlingLogSettings 4 | 5 | def setExtractSessionAttributes(extractSessionAttributes: String): Unit = { 6 | gatlingLogSettings.extractSessionAttributes = Some(extractSessionAttributes) 7 | } 8 | 9 | def setExcludeResources(excludeResources: Boolean): Unit = { 10 | gatlingLogSettings.excludeResources = Some(excludeResources) 11 | } 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/main/scala/WsLog.scala: -------------------------------------------------------------------------------- 1 | final case class WsLog( 2 | infoPart: String, 3 | sessionPart: String, 4 | checkPart: Option[String], 5 | requestPart: String, 6 | responsePart: Option[String] 7 | ) 8 | -------------------------------------------------------------------------------- /src/test/resources/simpleGet.txt: -------------------------------------------------------------------------------- 1 | >>>>>>>>>>>>>>>>>>>>>>>>>> 2 | Request: 3 | get request: OK 4 | ========================= 5 | Session: 6 | Session(Example scenario,1,Map(gatling.http.ssl.sslContexts -> io.gatling.http.util.SslContexts@434d148, gatling.http.cache.dns -> io.gatling.http.resolver.ShufflingNameResolver@105cb8b8, gatling.http.cache.baseUrl -> https://httpbin.org, identifier -> ),OK,List(),io.gatling.core.protocol.ProtocolComponentsRegistry$$Lambda$529/0x0000000800604840@1e8ad005,io.netty.channel.nio.NioEventLoop@60723d6a) 7 | ========================= 8 | HTTP request: 9 | GET https://httpbin.org/get 10 | headers: 11 | accept: */* 12 | host: httpbin.org 13 | ========================= 14 | HTTP response: 15 | status: 16 | 200 OK 17 | headers: 18 | Date: Wed, 25 Aug 2021 08:31:38 GMT 19 | Content-Type: application/json 20 | Connection: keep-alive 21 | Server: gunicorn/19.9.0 22 | Access-Control-Allow-Origin: * 23 | Access-Control-Allow-Credentials: true 24 | content-length: 223 25 | 26 | body: 27 | { 28 | "args": {}, 29 | "headers": { 30 | "Accept": "*/*", 31 | "Host": "httpbin.org", 32 | "X-Amzn-Trace-Id": "Root=1-6125ffea-3e25d40360dd3cc425c1a26f" 33 | }, 34 | "origin": "2.2.2.2", 35 | "url": "https://httpbin.org/get" 36 | } 37 | 38 | <<<<<<<<<<<<<<<<<<<<<<<<< -------------------------------------------------------------------------------- /src/test/resources/simplePost.txt: -------------------------------------------------------------------------------- 1 | >>>>>>>>>>>>>>>>>>>>>>>>>> 2 | Request: 3 | post request: KO jsonPath($.origin).find.is(1.1.1.1), but actually found 2.2.2.2 4 | ========================= 5 | Session: 6 | Session(Example scenario,1,Map(gatling.http.ssl.sslContexts -> io.gatling.http.util.SslContexts@72dcbe5f, gatling.http.cache.dns -> io.gatling.http.resolver.ShufflingNameResolver@9646ea9, gatling.http.cache.baseUrl -> https://httpbin.org, identifier -> 123),KO,List(),io.gatling.core.protocol.ProtocolComponentsRegistry$$Lambda$558/0x000000080065b840@5205309,io.netty.channel.nio.NioEventLoop@3241713e) 7 | ========================= 8 | HTTP request: 9 | POST https://httpbin.org/post 10 | headers: 11 | accept: application/json 12 | host: httpbin.org 13 | content-type: application/json 14 | content-length: 29 15 | body:StringChunksRequestBody{contentType='application/json', charset=UTF-8, content={ 16 | "someKey" : "someValue" 17 | }} 18 | ========================= 19 | HTTP response: 20 | status: 21 | 200 OK 22 | headers: 23 | Date: Fri, 27 Aug 2021 08:58:01 GMT 24 | Content-Type: application/json 25 | Connection: keep-alive 26 | Server: gunicorn/19.9.0 27 | Access-Control-Allow-Origin: * 28 | Access-Control-Allow-Credentials: true 29 | content-length: 433 30 | 31 | body: 32 | { 33 | "args": {}, 34 | "data": "{\n \"someKey\" : \"someValue\"\n}", 35 | "files": {}, 36 | "form": {}, 37 | "headers": { 38 | "Accept": "application/json", 39 | "Content-Length": "29", 40 | "Content-Type": "application/json", 41 | "Host": "httpbin.org", 42 | "X-Amzn-Trace-Id": "Root=1-6128a919-7d81641a7210d573410c6592" 43 | }, 44 | "json": { 45 | "someKey": "someValue" 46 | }, 47 | "origin": "2.2.2.2", 48 | "url": "https://httpbin.org/post" 49 | } 50 | 51 | <<<<<<<<<<<<<<<<<<<<<<<<< -------------------------------------------------------------------------------- /src/test/resources/withoutResponse.txt: -------------------------------------------------------------------------------- 1 | >>>>>>>>>>>>>>>>>>>>>>>>>> 2 | Request: 3 | get request: KO i.g.h.c.i.RequestTimeoutException: Request timeout to www.httpbin.org/54.156.165.4:443 after 60000 ms 4 | ========================= 5 | Session: 6 | Session(Example scenario,1,Map(gatling.http.ssl.sslContexts -> io.gatling.http.util.SslContexts@434d148, gatling.http.cache.dns -> io.gatling.http.resolver.ShufflingNameResolver@105cb8b8, gatling.http.cache.baseUrl -> https://httpbin.org, identifier -> ),OK,List(),io.gatling.core.protocol.ProtocolComponentsRegistry$$Lambda$529/0x0000000800604840@1e8ad005,io.netty.channel.nio.NioEventLoop@60723d6a) 7 | ========================= 8 | HTTP request: 9 | GET https://httpbin.org/get 10 | headers: 11 | accept: */* 12 | host: httpbin.org 13 | ========================= 14 | HTTP response: 15 | <<<<<<<<<<<<<<<<<<<<<<<<< -------------------------------------------------------------------------------- /src/test/resources/ws.txt: -------------------------------------------------------------------------------- 1 | >>>>>>>>>>>>>>>>>>>>>>>>>> 2 | Request: 3 | WS: Send Auth: KO Check timeout 4 | ========================= 5 | Session: 6 | Session(Example scenario,1,HashMap(gatling.http.cache.wsBaseUrl -> wss://mm-websocket.site.com, gatling.http.cache.dns -> io.gatling.http.resolver.ShufflingNameResolver@3e6f4720, gatling.http.webSocket -> io.gatling.http.action.ws.fsm.WsFsm@530baf68, gatling.http.cookies -> CookieJar(Map(CookieKey(awsalbapp-0,mm-websocket.site.com,/) -> StoredCookie(AWSALBAPP-0=_remove_, path=/, maxAge=604800s,true,true,1676562638435), CookieKey(awsalbapp-1,mm-websocket.site.com,/) -> StoredCookie(AWSALBAPP-1=_remove_, path=/, maxAge=604800s,true,true,1676562638435), CookieKey(awsalbapp-2,mm-websocket.site.com,/) -> StoredCookie(AWSALBAPP-2=_remove_, path=/, maxAge=604800s,true,true,1676562638435), CookieKey(awsalbapp-3,mm-websocket.site.com,/) -> StoredCookie(AWSALBAPP-3=_remove_, path=/, maxAge=604800s,true,true,1676562638435))), simulation -> example, gatling.http.ssl.sslContexts -> io.gatling.http.util.SslContexts@731da136),OK,List(),io.gatling.core.protocol.ProtocolComponentsRegistry$$Lambda$574/0x00000008006c7840@470039f4,io.netty.channel.nio.NioEventLoop@2227a6c1) 7 | ========================= 8 | WebSocket check: 9 | Wait DELETED 10 | ========================= 11 | WebSocket request: 12 | {"auth":{"userId":123}} 13 | ========================= 14 | WebSocket received messages: 15 | 17:50:41.612 [0] -> {"type":"EXPORT_PROCESS","status":"DISPATCHED"} 16 | 17:50:41.741 [1] -> {"type":"EXPORT_PROCESS","status":"RECEIVED"} 17 | 17:50:41.830 [2] -> {"type":"EXPORT_PROCESS","status":"RECEIVED"} 18 | 17:50:42.073 [3] -> {"type":"EXPORT_PROCESS","status":"RECEIVED"} 19 | 17:50:44.209 [4] -> {"type":"EXPORT_PROCESS","status":"RECEIVED"} 20 | 17:50:44.379 [5] -> {"type":"EXPORT_PROCESS","status":"RECEIVED"} 21 | 17:50:44.437 [6] -> {"type":"EXPORT_PROCESS","status":"COMPLETED"} 22 | <<<<<<<<<<<<<<<<<<<<<<<<< -------------------------------------------------------------------------------- /src/test/resources/wsWithoutCheck.txt: -------------------------------------------------------------------------------- 1 | >>>>>>>>>>>>>>>>>>>>>>>>>> 2 | Request: 3 | send: OK 4 | ========================= 5 | Session: 6 | Session(Example scenario,1,HashMap(gatling.http.cache.wsBaseUrl -> wss://mm-websocket.site.com, gatling.http.cache.dns -> io.gatling.http.resolver.ShufflingNameResolver@16d27311, gatling.http.webSocket -> io.gatling.http.action.ws.fsm.WsFsm@7f30b1d9, gatling.http.cookies -> CookieJar(Map(CookieKey(awsalbapp-0,mm-websocket.site.com,/) -> StoredCookie(AWSALBAPP-0=_remove_, path=/, maxAge=604800s,true,true,1676973873417), CookieKey(awsalbapp-1,mm-websocket.site.com,/) -> StoredCookie(AWSALBAPP-1=_remove_, path=/, maxAge=604800s,true,true,1676973873417), CookieKey(awsalbapp-2,mm-websocket.site.com,/) -> StoredCookie(AWSALBAPP-2=_remove_, path=/, maxAge=604800s,true,true,1676973873417), CookieKey(awsalbapp-3,mm-websocket.site.com,/) -> StoredCookie(AWSALBAPP-3=_remove_, path=/, maxAge=604800s,true,true,1676973873417))), simulation -> example, gatling.http.ssl.sslContexts -> io.gatling.http.util.SslContexts@759335cf),OK,List(),io.gatling.core.protocol.ProtocolComponentsRegistry$$Lambda$550/0x00000008006b4040@37b2d1fe,io.netty.channel.nio.NioEventLoop@79ab34c1) 7 | ========================= 8 | WebSocket request: 9 | {"auth":{"userId":123}} 10 | ========================= 11 | WebSocket received messages: 12 | <<<<<<<<<<<<<<<<<<<<<<<<< -------------------------------------------------------------------------------- /src/test/scala/HttpLogTests.scala: -------------------------------------------------------------------------------- 1 | import TestHelpers._ 2 | import org.scalatest.funsuite.AnyFunSuite 3 | import org.scalatest.matchers.should.Matchers.{be, convertToAnyShouldWrapper} 4 | 5 | class HttpLogTests extends AnyFunSuite { 6 | 7 | test("Fields matching") { 8 | val expected = Set("status_code", "method", "session", "response_body", "message", "userId", "url", "request_name", 9 | "protocol", "response_headers", "request_body", "scenario", "request_headers") 10 | 11 | val result = parseHttpLog("simpleGet.txt") 12 | 13 | assert(result.keySet == expected) 14 | } 15 | 16 | test("Compare values - [get]") { 17 | val result = parseHttpLog("simpleGet.txt") 18 | 19 | softAssert( 20 | withClue("Request name: ")(result("request_name") should be("get request")), 21 | withClue("Message: ")(result("message") should be("OK")), 22 | withClue("Session: ")(result("session") should be("Session(Example scenario,1,Map(gatling.http.ssl.sslContexts -> io.gatling.http.util.SslContexts@434d148, gatling.http.cache.dns -> io.gatling.http.resolver.ShufflingNameResolver@105cb8b8, gatling.http.cache.baseUrl -> https://httpbin.org, identifier -> ),OK,List(),io.gatling.core.protocol.ProtocolComponentsRegistry$$Lambda$529/0x0000000800604840@1e8ad005,io.netty.channel.nio.NioEventLoop@60723d6a)")), 23 | withClue("Method: ")(result("method") should be("GET")), 24 | withClue("Request body: ")(result("request_body") should be("%empty%")), 25 | withClue("Request headers: ")(result("request_headers") should be("accept: */*\nhost: httpbin.org")), 26 | withClue("Url: ")(result("url") should be("https://httpbin.org/get")), 27 | withClue("Status code: ")(result("status_code") should be("200")), 28 | withClue("Response headers: ")(result("response_headers") should be("Date: Wed, 25 Aug 2021 08:31:38 GMT\nContent-Type: application/json\nConnection: keep-alive\nServer: gunicorn/19.9.0\nAccess-Control-Allow-Origin: *\nAccess-Control-Allow-Credentials: true\ncontent-length: 223")), 29 | withClue("Response body: ")(result("response_body") should be("{\n \"args\": {},\n \"headers\": {\n \"Accept\": \"*/*\",\n \"Host\": \"httpbin.org\",\n \"X-Amzn-Trace-Id\": \"Root=1-6125ffea-3e25d40360dd3cc425c1a26f\"\n },\n \"origin\": \"2.2.2.2\",\n \"url\": \"https://httpbin.org/get\"\n}\n")), 30 | withClue("Protocol: ")(result("protocol") should be("http")), 31 | withClue("Scenario: ")(result("scenario") should be("Example scenario")), 32 | withClue("UserId: ")(result("userId") should be("1")), 33 | ) 34 | } 35 | 36 | test("Compare values - [post]") { 37 | val result = parseHttpLog("simplePost.txt") 38 | 39 | softAssert( 40 | withClue("Request name: ")(result("request_name") should be("post request")), 41 | withClue("Message: ")(result("message") should be("KO jsonPath($.origin).find.is(1.1.1.1), but actually found 2.2.2.2")), 42 | withClue("Session: ")(result("session") should be("Session(Example scenario,1,Map(gatling.http.ssl.sslContexts -> io.gatling.http.util.SslContexts@72dcbe5f, gatling.http.cache.dns -> io.gatling.http.resolver.ShufflingNameResolver@9646ea9, gatling.http.cache.baseUrl -> https://httpbin.org, identifier -> 123),KO,List(),io.gatling.core.protocol.ProtocolComponentsRegistry$$Lambda$558/0x000000080065b840@5205309,io.netty.channel.nio.NioEventLoop@3241713e)")), 43 | withClue("Method: ")(result("method") should be("POST")), 44 | withClue("Request body: ")(result("request_body") should be("StringChunksRequestBody{contentType='application/json', charset=UTF-8, content={\n \"someKey\" : \"someValue\"\n}}")), 45 | withClue("Request headers: ")(result("request_headers") should be("accept: application/json\nhost: httpbin.org\ncontent-type: application/json\ncontent-length: 29")), 46 | withClue("Url: ")(result("url") should be("https://httpbin.org/post")), 47 | withClue("Status code: ")(result("status_code") should be("200")), 48 | withClue("Response headers: ")(result("response_headers") should be("Date: Fri, 27 Aug 2021 08:58:01 GMT\nContent-Type: application/json\nConnection: keep-alive\nServer: gunicorn/19.9.0\nAccess-Control-Allow-Origin: *\nAccess-Control-Allow-Credentials: true\ncontent-length: 433")), 49 | withClue("Response body: ")(result("response_body") should be("{\n \"args\": {},\n \"data\": \"{\\n \\\"someKey\\\" : \\\"someValue\\\"\\n}\",\n \"files\": {},\n \"form\": {},\n \"headers\": {\n \"Accept\": \"application/json\",\n \"Content-Length\": \"29\",\n \"Content-Type\": \"application/json\",\n \"Host\": \"httpbin.org\",\n \"X-Amzn-Trace-Id\": \"Root=1-6128a919-7d81641a7210d573410c6592\"\n },\n \"json\": {\n \"someKey\": \"someValue\"\n },\n \"origin\": \"2.2.2.2\",\n \"url\": \"https://httpbin.org/post\"\n}\n")), 50 | withClue("Protocol: ")(result("protocol") should be("http")), 51 | withClue("Scenario: ")(result("scenario") should be("Example scenario")), 52 | withClue("UserId: ")(result("userId") should be("1")), 53 | ) 54 | } 55 | 56 | test("Without response body") { 57 | val result = parseHttpLog("withoutResponse.txt") 58 | softAssert( 59 | withClue("Response body: ")(result("response_body") should be("%empty%")), 60 | withClue("Response headers: ")(result("response_headers") should be("%empty%")), 61 | withClue("Status code: ")(result("status_code") should be("%empty%")) 62 | ) 63 | } 64 | 65 | test("Extract Session Attributes") { 66 | val result = parseHttpLog("simplePost.txt", 67 | Some("gatling.http.cache.baseUrl;gatling.http.cache.dns;identifier")) 68 | softAssert( 69 | withClue("Param[1] gatling.http.cache.baseUrl")(result("gatling.http.cache.baseUrl") should be("https://httpbin.org")), 70 | withClue("Param[2] gatling.http.cache.dns")(result("gatling.http.cache.dns") should be("io.gatling.http.resolver.ShufflingNameResolver@9646ea9")), 71 | withClue("Param[3] identifier")(result("identifier") should be("123")), 72 | ) 73 | } 74 | 75 | test("Extract WS") { 76 | val result = parseHttpLog("ws.txt", parserType = "ws") 77 | softAssert( 78 | withClue("Message: ")(result("message") should be("KO Check timeout")), 79 | withClue("Url: ")(result("url") should be("wss://mm-websocket.site.com")), 80 | withClue("Request body: ")(result("request_body") should be("""{"auth":{"userId":123}}""")), 81 | withClue("Response body: ")(result("response_body") should be( 82 | """17:50:41.612 [0] -> {"type":"EXPORT_PROCESS","status":"DISPATCHED"} 83 | |17:50:41.741 [1] -> {"type":"EXPORT_PROCESS","status":"RECEIVED"} 84 | |17:50:41.830 [2] -> {"type":"EXPORT_PROCESS","status":"RECEIVED"} 85 | |17:50:42.073 [3] -> {"type":"EXPORT_PROCESS","status":"RECEIVED"} 86 | |17:50:44.209 [4] -> {"type":"EXPORT_PROCESS","status":"RECEIVED"} 87 | |17:50:44.379 [5] -> {"type":"EXPORT_PROCESS","status":"RECEIVED"} 88 | |17:50:44.437 [6] -> {"type":"EXPORT_PROCESS","status":"COMPLETED"}""".stripMargin)), 89 | withClue("Check name: ")(result("check_name") should be("Wait DELETED")), 90 | withClue("Protocol: ")(result("protocol") should be("ws")), 91 | ) 92 | } 93 | 94 | test("Extract WS w/o check") { 95 | val result = parseHttpLog("wsWithoutCheck.txt", parserType = "ws") 96 | softAssert( 97 | withClue("Message: ")(result("message") should be("OK")), 98 | withClue("Url: ")(result("url") should be("wss://mm-websocket.site.com")), 99 | withClue("Request body: ")(result("request_body") should be("""{"auth":{"userId":123}}""")), 100 | withClue("Response body: ")(result("response_body") should be("%empty%")), 101 | withClue("Check name: ")(result("check_name") should be("%empty%")), 102 | withClue("Protocol: ")(result("protocol") should be("ws")), 103 | ) 104 | } 105 | 106 | } 107 | -------------------------------------------------------------------------------- /src/test/scala/TestHelpers.scala: -------------------------------------------------------------------------------- 1 | import GatlingLogParser.{httpFields, wsFields} 2 | import com.fasterxml.jackson.core.{JsonFactory, JsonToken} 3 | 4 | import java.io.StringWriter 5 | import scala.collection.mutable 6 | import scala.io.Source 7 | import scala.util.Using 8 | import org.scalatest.Checkpoints._ 9 | 10 | object TestHelpers { 11 | 12 | private val factory = new JsonFactory 13 | 14 | private def readFile(filename: String): String = { 15 | val path = getClass.getClassLoader.getResource(filename).getPath 16 | Using(Source.fromFile(path))(_.mkString).getOrElse("") 17 | } 18 | 19 | private def fetchContent(fullMessage: String, extractSessionAttributes: Option[String], parserType: String): String = { 20 | val jsonObjectWriter = new StringWriter 21 | val generator = factory.createGenerator(jsonObjectWriter) 22 | generator.useDefaultPrettyPrinter 23 | generator.writeStartObject() 24 | 25 | parserType match { 26 | case "http" => httpFields(generator, fullMessage, extractSessionAttributes) 27 | case "ws" => wsFields(generator, fullMessage, extractSessionAttributes) 28 | } 29 | 30 | generator.close() 31 | jsonObjectWriter.toString 32 | } 33 | 34 | def parseHttpLog(fileName: String, extractSessionAttributes: Option[String] = None, parserType: String = "http") = { 35 | val raw = readFile(fileName) 36 | 37 | val parsedContent = parserType match { 38 | case "http" => fetchContent(raw, extractSessionAttributes, "http") 39 | case "ws" => fetchContent(raw, extractSessionAttributes, "ws") 40 | } 41 | 42 | // parse and add to map for test 43 | val parser = factory.createParser(parsedContent) 44 | val result: mutable.HashMap[String, String] = new mutable.HashMap 45 | 46 | if (parser.nextToken().equals(JsonToken.START_OBJECT)) { 47 | while (JsonToken.END_OBJECT != parser.nextToken()) { 48 | val k = parser.getCurrentName 49 | parser.nextToken() 50 | val v = parser.getText() 51 | result += (k -> v) 52 | } 53 | } 54 | result 55 | } 56 | 57 | implicit class CheckpointWrapper[A](function: => A) { 58 | def apply(): A = function 59 | } 60 | 61 | def softAssert[Assertion](attempts: CheckpointWrapper[Assertion]*): Unit = { 62 | val softy = new Checkpoint 63 | attempts.foreach(assert => softy(assert.apply())) 64 | softy.reportAll() 65 | } 66 | 67 | } 68 | --------------------------------------------------------------------------------