├── .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 [](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 | 
269 |
270 |
271 | ## Schema
272 | 
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 |
--------------------------------------------------------------------------------