spl2-testing-framework 1.7.3__tar.gz → 1.8.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/PKG-INFO +34 -7
  2. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/README.md +33 -6
  3. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/pyproject.toml +1 -1
  4. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/conftest.py +85 -11
  5. spl2_testing_framework-1.8.1/spl2_testing_framework/spl2_test_config.json +18 -0
  6. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/tools/performance.py +13 -4
  7. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/tools/results.py +1 -3
  8. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/tools/spl2test_runner.py +77 -22
  9. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/tools/splunk_integration/splunk_handler.py +17 -6
  10. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/tools/test_discovery.py +9 -9
  11. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/tools/test_types.py +2 -2
  12. spl2_testing_framework-1.8.1/spl2_testing_framework/tools/utils.py +331 -0
  13. spl2_testing_framework-1.7.3/spl2_testing_framework/spl2_test_config.json +0 -18
  14. spl2_testing_framework-1.7.3/spl2_testing_framework/tools/utils.py +0 -159
  15. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/LICENSE +0 -0
  16. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/__init__.py +0 -0
  17. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/launch_remotely_spl2.py +0 -0
  18. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/logger_manager.py +0 -0
  19. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/pytest.ini.sample +0 -0
  20. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/single_spl2_file_runner.py +0 -0
  21. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/spl2_utils/assertions.spl2 +0 -0
  22. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/spl2_utils/logs_to_metrics.spl2 +0 -0
  23. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/test_runner.py +0 -0
  24. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/tools/__init__.py +0 -0
  25. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/tools/create_diff_sheet.py +0 -0
  26. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/tools/jobs/__init__.py +0 -0
  27. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/tools/jobs/cli_job.py +0 -0
  28. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/tools/jobs/cloud_job.py +0 -0
  29. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/tools/jobs/job.py +0 -0
  30. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/tools/jobs/splunk_job.py +0 -0
  31. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/tools/jobs/test_cloud_job.py +0 -0
  32. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/tools/search_clients/__init__.py +0 -0
  33. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/tools/search_clients/cli_search_client.py +0 -0
  34. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/tools/search_clients/cloud_search_client.py +0 -0
  35. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/tools/search_clients/search_client.py +0 -0
  36. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/tools/search_clients/splunk_search_client.py +0 -0
  37. {spl2_testing_framework-1.7.3 → spl2_testing_framework-1.8.1}/spl2_testing_framework/tools/splunk_integration/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: spl2-testing-framework
3
- Version: 1.7.3
3
+ Version: 1.8.1
4
4
  Summary: SPL2 Testing Framework
5
5
  Home-page: https://github.com/splunk/spl2-testing-framework
6
6
  Keywords: SPL2 testing framework
@@ -35,7 +35,7 @@ Description-Content-Type: text/markdown
35
35
 
36
36
  ## Overview
37
37
 
38
- The **SPL2 Testing Framework** enables running SPL2 tests both locally ( or on any Splunk instance with SPL2
38
+ The **SPL2 Testing Framework** enables running SPL2 tests both locally (or on any Splunk instance with SPL2
39
39
  orchestrator), remotely (using external cloud environments) or using cli.
40
40
 
41
41
  - **For Cloud**: It uses
@@ -138,6 +138,9 @@ Additionally, the following options are supported:
138
138
  * `--create_comparison_sheet` - to create a comparison sheet in `comparison_box_test` folder using actual and expected outputs (works only when running box tests)
139
139
  * `--cli_bench` - when running tests with CLI, this option enables benchmarking mode by adding `-b` and `-n` flags to the spl2-processor-cli command. The value specifies the number of events to test (e.g., `--cli_bench=1000` will add `-b -n 1000` to the CLI command). **In bench mode, all tests always succeed and CLI output is printed to logs** - this is useful for performance testing and benchmarking. This is only applicable when using `--type cli`.
140
140
  * `--check_splunk_results` - option to validate box test output by sending events to a Splunk instance (using HEC) and querying them back, with support for **CIM** (Common Information Model) and **TA** (Technology Add-on) checks.
141
+ * `--reduction_stats` - when set, **box tests** record a reduction rate per run: input `_raw` vs output `_raw` (after pairing input/output events). Rates are grouped by **pipeline** (the `.spl2` code module stem) and **sourcetype** (from the input event if set, else the output event, else `UNKNOWN`). Off by default.
142
+ * `--reduction_stats_file` - path to a JSON file written at the **end of the pytest session** with collected rates (default `reduction_stats.json`). Only used when `--reduction_stats` is set.
143
+ * `--reduction_stats_full` - when set together with `--reduction_stats`, each pipeline’s JSON payload also includes a pandas **`describe()`** summary (count, mean, std, min, quartiles, max) per sourcetype column. Only used when `--reduction_stats` is set.
141
144
 
142
145
  * Note: The `pytest.ini.sample` file allows you to define command parameters. Just update the configurations, rename the
143
146
  file by removing the `.sample` extension, and execute the command.
@@ -153,6 +156,7 @@ Sample configuration which may be used:
153
156
  - Module: `spl2_testing_framework.test_runner`
154
157
  - Parameters:
155
158
  `--type [cli | splunk | cloud] --test_dir /tests/resources -o log_cli=true --log-cli-level=INFO --verbose`
159
+ - Optional: append `--reduction_stats` (and `--reduction_stats_file`, `--reduction_stats_full` if needed) to collect box-test reduction JSON as described above.
156
160
  - If test dir is not specified, current working directory will be used
157
161
  - Use `--code_dir` if SPL2 code modules are in a separate directory from tests
158
162
  - If necessary another pytest options can be added
@@ -252,7 +256,31 @@ command ("|")
252
256
  Content of this file will also be printed to stdout.
253
257
 
254
258
  This checks can be applied only to box tests, as assertions which are used in unit tests may impact spl2 pipeline
255
- performance.
259
+ performance.
260
+
261
+ ### Reduction rate statistics (box tests)
262
+
263
+ Optional telemetry for **how much** each pipeline shrinks event payloads (length of input `_raw` vs matched output `_raw`), broken down by **pipeline** and **sourcetype**.
264
+
265
+ **Enable**
266
+
267
+ - Pass `--reduction_stats` to pytest (or add it in `pytest.ini` / your IDE run configuration).
268
+
269
+ **Output**
270
+
271
+ - During the session, each box test appends one or more floating-point rates into an in-memory structure: `pipeline_name → sourcetype → [rates…]`.
272
+ - After all tests finish, if `--reduction_stats` was set, the framework logs a short **pandas** summary per pipeline and writes **`--reduction_stats_file`** (default `reduction_stats.json`).
273
+ - With **`--reduction_stats_full`**, the JSON file also embeds per-pipeline **`describe()`** statistics for each sourcetype column (handy for dashboards or quick distribution checks).
274
+
275
+ **How rates are computed (high level)**
276
+
277
+ - Input events are parsed from the box test `source` string; output rows come from the job’s destination results.
278
+ - Input/output rows are **paired** using similarity on `_raw` (with a threshold and a same-length positional fallback). Only **matched** pairs contribute rates.
279
+ - For each pair, lengths use a **canonical JSON length** when `_raw` parses as JSON or a Python literal; otherwise the raw string length is used, so identical logical content can yield a **0** reduction rate even if string formatting differs.
280
+
281
+ **Scope**
282
+
283
+ - Applies to **box tests** only. Single-SPL2 runs and unit tests do not populate reduction stats.
256
284
 
257
285
  ### Check splunk results
258
286
 
@@ -263,13 +291,13 @@ When set (`--check_splunk_results=<option>`), box tests (after running the SPL2
263
291
  | **CIM** | For each output event, ingest via HEC, load back from Splunk, then check CIM fields from `expected_cim_fields.cim_fields` with `validate_compatibility(actual, expected)`. Skips if the box test has no `cim_fields`. |
264
292
  | **TA** | For each output event, ingest via HEC, load back from Splunk, then assert equality with the expected row for that index. |
265
293
 
266
- For **TA** only, you can name top-level fields to drop on **both** the queried event and each `expected_destination_result` row by adding **`ignore_fields_in_splunk_check`**: an object mapping **field name → short reason**. Use the same key under `test` in each entry in `*.test.json` (same file as `expected_destination_result`):
294
+ You can name top-level fields to drop on **both** the pipeline/actual row and each matching `expected_destination_result` row before they are compared by adding **`ignore_fields_in_destination_compare`**: an object mapping **field name → short reason**. Use the same key under `test` in each entry in `*.test.json` (same file as `expected_destination_result`):
267
295
 
268
296
  ```json
269
297
  "test": {
270
298
  "source": "...",
271
299
  "expected_destination_result": [ ... ],
272
- "ignore_fields_in_splunk_check": {
300
+ "ignore_fields_in_destination_compare": {
273
301
  "_raw": "Raw event differs after Splunk indexing",
274
302
  "_time": "Timestamp may vary due to ingestion latency"
275
303
  }
@@ -277,8 +305,7 @@ For **TA** only, you can name top-level fields to drop on **both** the queried e
277
305
  ```
278
306
 
279
307
 
280
- If the key is omitted, no fields are ignored. Used only when `--check_splunk_results=TA` (ignored for other modes and for the default
281
- in-memory box test assert). If the file or key is missing, no fields are ignored.
308
+ If the key is omitted, no fields are ignored. It applies to the **default in-memory** box test assert (no `--check_splunk_results`) and to **`--check_splunk_results=TA`**. It does **not** apply to **`--check_splunk_results=CIM`**. If the file or key is missing, no fields are ignored.
282
309
 
283
310
  #### Configuration:
284
311
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- The **SPL2 Testing Framework** enables running SPL2 tests both locally ( or on any Splunk instance with SPL2
5
+ The **SPL2 Testing Framework** enables running SPL2 tests both locally (or on any Splunk instance with SPL2
6
6
  orchestrator), remotely (using external cloud environments) or using cli.
7
7
 
8
8
  - **For Cloud**: It uses
@@ -105,6 +105,9 @@ Additionally, the following options are supported:
105
105
  * `--create_comparison_sheet` - to create a comparison sheet in `comparison_box_test` folder using actual and expected outputs (works only when running box tests)
106
106
  * `--cli_bench` - when running tests with CLI, this option enables benchmarking mode by adding `-b` and `-n` flags to the spl2-processor-cli command. The value specifies the number of events to test (e.g., `--cli_bench=1000` will add `-b -n 1000` to the CLI command). **In bench mode, all tests always succeed and CLI output is printed to logs** - this is useful for performance testing and benchmarking. This is only applicable when using `--type cli`.
107
107
  * `--check_splunk_results` - option to validate box test output by sending events to a Splunk instance (using HEC) and querying them back, with support for **CIM** (Common Information Model) and **TA** (Technology Add-on) checks.
108
+ * `--reduction_stats` - when set, **box tests** record a reduction rate per run: input `_raw` vs output `_raw` (after pairing input/output events). Rates are grouped by **pipeline** (the `.spl2` code module stem) and **sourcetype** (from the input event if set, else the output event, else `UNKNOWN`). Off by default.
109
+ * `--reduction_stats_file` - path to a JSON file written at the **end of the pytest session** with collected rates (default `reduction_stats.json`). Only used when `--reduction_stats` is set.
110
+ * `--reduction_stats_full` - when set together with `--reduction_stats`, each pipeline’s JSON payload also includes a pandas **`describe()`** summary (count, mean, std, min, quartiles, max) per sourcetype column. Only used when `--reduction_stats` is set.
108
111
 
109
112
  * Note: The `pytest.ini.sample` file allows you to define command parameters. Just update the configurations, rename the
110
113
  file by removing the `.sample` extension, and execute the command.
@@ -120,6 +123,7 @@ Sample configuration which may be used:
120
123
  - Module: `spl2_testing_framework.test_runner`
121
124
  - Parameters:
122
125
  `--type [cli | splunk | cloud] --test_dir /tests/resources -o log_cli=true --log-cli-level=INFO --verbose`
126
+ - Optional: append `--reduction_stats` (and `--reduction_stats_file`, `--reduction_stats_full` if needed) to collect box-test reduction JSON as described above.
123
127
  - If test dir is not specified, current working directory will be used
124
128
  - Use `--code_dir` if SPL2 code modules are in a separate directory from tests
125
129
  - If necessary another pytest options can be added
@@ -219,7 +223,31 @@ command ("|")
219
223
  Content of this file will also be printed to stdout.
220
224
 
221
225
  This checks can be applied only to box tests, as assertions which are used in unit tests may impact spl2 pipeline
222
- performance.
226
+ performance.
227
+
228
+ ### Reduction rate statistics (box tests)
229
+
230
+ Optional telemetry for **how much** each pipeline shrinks event payloads (length of input `_raw` vs matched output `_raw`), broken down by **pipeline** and **sourcetype**.
231
+
232
+ **Enable**
233
+
234
+ - Pass `--reduction_stats` to pytest (or add it in `pytest.ini` / your IDE run configuration).
235
+
236
+ **Output**
237
+
238
+ - During the session, each box test appends one or more floating-point rates into an in-memory structure: `pipeline_name → sourcetype → [rates…]`.
239
+ - After all tests finish, if `--reduction_stats` was set, the framework logs a short **pandas** summary per pipeline and writes **`--reduction_stats_file`** (default `reduction_stats.json`).
240
+ - With **`--reduction_stats_full`**, the JSON file also embeds per-pipeline **`describe()`** statistics for each sourcetype column (handy for dashboards or quick distribution checks).
241
+
242
+ **How rates are computed (high level)**
243
+
244
+ - Input events are parsed from the box test `source` string; output rows come from the job’s destination results.
245
+ - Input/output rows are **paired** using similarity on `_raw` (with a threshold and a same-length positional fallback). Only **matched** pairs contribute rates.
246
+ - For each pair, lengths use a **canonical JSON length** when `_raw` parses as JSON or a Python literal; otherwise the raw string length is used, so identical logical content can yield a **0** reduction rate even if string formatting differs.
247
+
248
+ **Scope**
249
+
250
+ - Applies to **box tests** only. Single-SPL2 runs and unit tests do not populate reduction stats.
223
251
 
224
252
  ### Check splunk results
225
253
 
@@ -230,13 +258,13 @@ When set (`--check_splunk_results=<option>`), box tests (after running the SPL2
230
258
  | **CIM** | For each output event, ingest via HEC, load back from Splunk, then check CIM fields from `expected_cim_fields.cim_fields` with `validate_compatibility(actual, expected)`. Skips if the box test has no `cim_fields`. |
231
259
  | **TA** | For each output event, ingest via HEC, load back from Splunk, then assert equality with the expected row for that index. |
232
260
 
233
- For **TA** only, you can name top-level fields to drop on **both** the queried event and each `expected_destination_result` row by adding **`ignore_fields_in_splunk_check`**: an object mapping **field name → short reason**. Use the same key under `test` in each entry in `*.test.json` (same file as `expected_destination_result`):
261
+ You can name top-level fields to drop on **both** the pipeline/actual row and each matching `expected_destination_result` row before they are compared by adding **`ignore_fields_in_destination_compare`**: an object mapping **field name → short reason**. Use the same key under `test` in each entry in `*.test.json` (same file as `expected_destination_result`):
234
262
 
235
263
  ```json
236
264
  "test": {
237
265
  "source": "...",
238
266
  "expected_destination_result": [ ... ],
239
- "ignore_fields_in_splunk_check": {
267
+ "ignore_fields_in_destination_compare": {
240
268
  "_raw": "Raw event differs after Splunk indexing",
241
269
  "_time": "Timestamp may vary due to ingestion latency"
242
270
  }
@@ -244,8 +272,7 @@ For **TA** only, you can name top-level fields to drop on **both** the queried e
244
272
  ```
245
273
 
246
274
 
247
- If the key is omitted, no fields are ignored. Used only when `--check_splunk_results=TA` (ignored for other modes and for the default
248
- in-memory box test assert). If the file or key is missing, no fields are ignored.
275
+ If the key is omitted, no fields are ignored. It applies to the **default in-memory** box test assert (no `--check_splunk_results`) and to **`--check_splunk_results=TA`**. It does **not** apply to **`--check_splunk_results=CIM`**. If the file or key is missing, no fields are ignored.
249
276
 
250
277
  #### Configuration:
251
278
 
@@ -15,7 +15,7 @@
15
15
 
16
16
  [tool.poetry]
17
17
  name = "spl2-testing-framework"
18
- version = "1.7.3"
18
+ version = "1.8.1"
19
19
  description = "SPL2 Testing Framework"
20
20
  authors = ["mgazda-splunk <mgazda@splunk.com>"]
21
21
  readme = "README.md"
@@ -14,19 +14,21 @@
14
14
 
15
15
 
16
16
  import json
17
+ import logging
18
+ import math
17
19
  import os
18
20
  from collections import defaultdict
19
21
 
20
- import pandas as pd
21
22
  import pytest
22
23
 
23
- from .tools.results import Results
24
- from .tools.splunk_integration.splunk_handler import SplunkHandler
25
-
26
24
  pytest.register_assert_rewrite("spl2_testing_framework")
27
25
 
28
26
  from requests.auth import AuthBase, HTTPBasicAuth
29
27
 
28
+ from .logger_manager import setup_logging
29
+ from .tools.results import Results
30
+ from .tools.splunk_integration.splunk_handler import SplunkHandler
31
+
30
32
  from spl2_testing_framework.tools.search_clients.cli_search_client import (
31
33
  CLISearchClient,
32
34
  )
@@ -58,10 +60,7 @@ TEST_DIR = "test_dir"
58
60
  CODE_DIR = "code_dir"
59
61
  LIMIT_TESTS = "limit_tests"
60
62
  CLI_BENCH = "cli_bench"
61
-
62
- import logging
63
-
64
- from .logger_manager import setup_logging
63
+ REDUCTION_STATS = "reduction_stats"
65
64
 
66
65
  setup_logging()
67
66
 
@@ -89,6 +88,24 @@ def pytest_addoption(parser):
89
88
  parser.addoption(f"--{IGNORE_ADDITIONAL_FIELDS_IN_ACTUAL}", action="store_true")
90
89
  parser.addoption(f"--{LIMIT_TESTS}", action="store", type=int, default=None)
91
90
  parser.addoption(f"--{CLI_BENCH}", action="store", type=int, default=None)
91
+ parser.addoption(
92
+ f"--{REDUCTION_STATS}",
93
+ action="store_true",
94
+ default=False,
95
+ help="Collect reduction rates per pipeline/sourcetype and save to file (see --reduction_stats_file, --reduction_stats_full). By default disabled.",
96
+ )
97
+ parser.addoption(
98
+ "--reduction_stats_file",
99
+ action="store",
100
+ default="reduction_stats.json",
101
+ help="Path to save reduction rates (JSON). Used only when --reduction_stats is set.",
102
+ )
103
+ parser.addoption(
104
+ "--reduction_stats_full",
105
+ action="store_true",
106
+ default=False,
107
+ help="Include full dataframe stats (count, mean, std, min, 25%%, 50%%, 75%%, max) in the saved file. Used only when --reduction_stats is set.",
108
+ )
92
109
 
93
110
 
94
111
  def pytest_generate_tests(metafunc):
@@ -169,7 +186,7 @@ def spl2_test_runner(
169
186
  url = f"https://{config['tenant']}.api.{config['cloud_instance']}/{config['tenant']}"
170
187
  search_client = CloudSearchClient(url=url, auth=bearer_token_authentication)
171
188
  else:
172
- raise Exception(
189
+ raise ValueError(
173
190
  "Unknown test type: {}. Supported tests types: 'splunk', 'cloud', 'cli'".format(
174
191
  test_type
175
192
  )
@@ -188,6 +205,9 @@ def run_configurations(request):
188
205
  ),
189
206
  CLI_BENCH: request.config.getoption(f"--{CLI_BENCH}"),
190
207
  CHECK_SPLUNK_RESULTS: request.config.getoption(f"--{CHECK_SPLUNK_RESULTS}"),
208
+ REDUCTION_STATS: request.config.getoption(
209
+ f"--{REDUCTION_STATS}", default=False
210
+ ),
191
211
  }
192
212
 
193
213
 
@@ -328,17 +348,71 @@ def splunk_handler(config, splunk_token_authentication):
328
348
 
329
349
 
330
350
  @pytest.fixture(scope="session")
331
- def stats():
351
+ def stats(request):
332
352
  s = defaultdict(lambda: defaultdict(list))
333
353
  yield s
334
354
 
355
+ collect_reduction = request.config.getoption(f"--{REDUCTION_STATS}", default=False)
356
+ if not collect_reduction:
357
+ return
358
+
359
+ _LOGGER.info("Reduction stats: reporting (--reduction_stats was set)")
360
+ if not s:
361
+ _LOGGER.info(
362
+ "Reduction stats: no data (no box tests ran or no rates collected)"
363
+ )
364
+ return
365
+
366
+ import pandas as pd # lazy import — only needed when --reduction_stats is set
367
+
368
+ def _pad_to_equal_length(rates_dict):
369
+ """Pad lists to same length with NaN so pd.DataFrame accepts different-length columns."""
370
+ if not rates_dict:
371
+ return rates_dict
372
+ max_len = max(len(lst) for lst in rates_dict.values())
373
+ return {
374
+ k: list(lst) + [float("nan")] * (max_len - len(lst))
375
+ for k, lst in rates_dict.items()
376
+ }
377
+
335
378
  for template, v in s.items():
336
- x = pd.DataFrame(v)
379
+ v_padded = _pad_to_equal_length(dict(v))
380
+ x = pd.DataFrame(v_padded)
337
381
  _LOGGER.info(" *** *** ***")
338
382
  _LOGGER.info("Collected statistics: ")
339
383
  _LOGGER.info("Template name: %s ", template)
340
384
  _LOGGER.info(x.describe())
341
385
 
386
+ out_path = request.config.getoption(
387
+ "--reduction_stats_file", default="reduction_stats.json"
388
+ )
389
+ include_full = request.config.getoption("--reduction_stats_full", default=False)
390
+ if out_path:
391
+ data = {}
392
+ for pipeline, sourcetype_rates in s.items():
393
+ rates_dict = dict(sourcetype_rates)
394
+ if include_full:
395
+ rates_padded = _pad_to_equal_length(rates_dict)
396
+ df = pd.DataFrame(rates_padded)
397
+ describe = df.describe().to_dict()
398
+ # NaN is not JSON-serializable; convert to None
399
+ describe_serializable = {
400
+ k: {
401
+ kk: (None if isinstance(vv, float) and math.isnan(vv) else vv)
402
+ for kk, vv in v.items()
403
+ }
404
+ for k, v in describe.items()
405
+ }
406
+ data[pipeline] = {
407
+ "rates": rates_dict,
408
+ "describe": describe_serializable,
409
+ }
410
+ else:
411
+ data[pipeline] = rates_dict
412
+ with open(out_path, "w", encoding="utf-8") as f:
413
+ json.dump(data, f, indent=2)
414
+ _LOGGER.info("Reduction rates saved to %s", out_path)
415
+
342
416
 
343
417
  def _set_results_options(metafunc):
344
418
  ignore_empty_strings = metafunc.config.getoption(IGNORE_EMPTY_STRINGS)
@@ -0,0 +1,18 @@
1
+ {
2
+ "host": "",
3
+ "cloud_instance": "",
4
+ "tenant": "",
5
+ "bearer_token": "",
6
+ "port": "",
7
+ "user": "",
8
+ "password": "",
9
+ "SPLUNK_INSTANCE": {
10
+ "ip": "localhost",
11
+ "port": 8088,
12
+ "api_port": 8089,
13
+ "username": "admin",
14
+ "password": "Chang3d!",
15
+ "index": "testx",
16
+ "hec_token": "4a8a737d-5452-426c-a6f7-106dca4e813f"
17
+ }
18
+ }
@@ -249,7 +249,14 @@ class PerformanceCheck:
249
249
 
250
250
  @staticmethod
251
251
  def _preserve_timestamps_through_fields(code_module: str) -> str:
252
- """Replace '| fields' keep-mode with a no-op so _timestamp_perf* fields are not stripped."""
252
+ """Replace '| fields' keep-mode with a no-op so _timestamp_perf* fields are not stripped.
253
+
254
+ During performance instrumentation the pipeline runs with a slightly different shape:
255
+ keep-mode ``| fields`` commands are removed to prevent them from stripping the injected
256
+ ``_timestamp_perf*`` variables. This means perf numbers reflect a pipeline with all
257
+ fields passing through, which is slightly heavier than production but avoids silently
258
+ dropping the instrumentation fields and producing zeroed measurements.
259
+ """
253
260
  return re.sub(
254
261
  r"\|\s*fields\s+(?!-)(.+)",
255
262
  r"| eval _perf_fields_preserved=1",
@@ -297,11 +304,13 @@ class PerformanceCheck:
297
304
  flags=re.M,
298
305
  )
299
306
 
300
- func_timestamps = re.findall(rf"_time_{f['f_name']}_placeholder", new_function)
307
+ func_timestamps = re.findall(
308
+ rf"_time_{re.escape(f['f_name'])}_placeholder", new_function
309
+ )
301
310
 
302
311
  for n, timestamp in enumerate(func_timestamps):
303
312
  new_function = re.sub(
304
- timestamp,
313
+ re.escape(timestamp),
305
314
  rf"_timestamp_perf{f['f_name']}_{n:03d}",
306
315
  new_function,
307
316
  count=1,
@@ -318,7 +327,7 @@ class PerformanceCheck:
318
327
  )
319
328
 
320
329
  func_timestamps = re.findall(
321
- rf"\$_time_{f['f_name']}_placeholder", new_function
330
+ rf"\$_time_{re.escape(f['f_name'])}_placeholder", new_function
322
331
  )
323
332
 
324
333
  for n, timestamp in enumerate(func_timestamps):
@@ -175,14 +175,12 @@ class Results(UserDict):
175
175
  actual_data[key][i], expected_data[key][i]
176
176
  )
177
177
 
178
- return actual_data
179
-
180
178
  def remove_fields_not_in_expected(self, expected):
181
179
  """Remove fields from this Results object that are not present in the expected Results object.
182
180
  This is the public method to be called when comparing results.
183
181
  """
184
182
  if self._ignore_additional_fields_in_actual and isinstance(expected, Results):
185
- self.data = self._remove_extra_fields(self.data, expected.data)
183
+ self._remove_extra_fields(self.data, expected.data)
186
184
  return self
187
185
 
188
186
  def __lt__(self, other):
@@ -33,9 +33,7 @@ from spl2_testing_framework.tools.test_types import UnitTest, BoxTest, SingleSPL
33
33
  from spl2_testing_framework.tools.utils import (
34
34
  _make_functions_visible_for_testing,
35
35
  validate_compatibility,
36
- calculate_reduction_rate,
37
- parse_raw_from_input,
38
- parse_raw_from_reduced_events,
36
+ calculate_reduction_rate_and_update_stats,
39
37
  )
40
38
 
41
39
  _LOGGER = logging.getLogger(__name__)
@@ -44,13 +42,21 @@ _LOGGER = logging.getLogger(__name__)
44
42
  class SPL2TestRunner:
45
43
  """Main class responsible for running tests"""
46
44
 
47
- def __init__(self, search_client, run_configurations, splunk_handler, stats):
45
+ def __init__(
46
+ self, search_client, run_configurations, splunk_handler=None, stats=None
47
+ ):
48
48
  self._search_client = search_client
49
49
  self.create_comparison_sheet = run_configurations.get("create_comparison_sheet")
50
50
  self.check_splunk_results = run_configurations.get("check_splunk_results")
51
+ self.collect_reduction_stats = run_configurations.get("reduction_stats", False)
51
52
  self.comp_obj = ComparisonSheet()
52
53
  self.reduction_stats = stats
53
54
  if self.check_splunk_results:
55
+ if splunk_handler is None:
56
+ raise ValueError(
57
+ f"check_splunk_results={self.check_splunk_results!r} requires a "
58
+ "splunk_handler to be provided, but got None."
59
+ )
54
60
  splunk_handler.setup()
55
61
  self.splunk_handler = splunk_handler
56
62
 
@@ -181,26 +187,48 @@ class SPL2TestRunner:
181
187
 
182
188
  perf.check_performance(output_result)
183
189
 
184
- reduction_rate = calculate_reduction_rate(
185
- parse_raw_from_input(box_test.input),
186
- parse_raw_from_reduced_events(output_result),
187
- )
188
- self.reduction_stats[box_test.name]["reduction_rate"].extend(reduction_rate)
189
-
190
- _LOGGER.info("Reduction rate for this event: %s", reduction_rate)
190
+ if self.collect_reduction_stats:
191
+ reduction_rate = calculate_reduction_rate_and_update_stats(
192
+ box_test.code_module_path.stem,
193
+ box_test.input,
194
+ output_result,
195
+ self.reduction_stats,
196
+ )
197
+ _LOGGER.info("Reduction rate for this event: %s", reduction_rate)
191
198
 
192
199
  if self.check_splunk_results:
193
200
  self._check_splunk_results(box_test, output_result)
194
201
  else:
195
202
  if box_test.output:
196
- # Remove fields from actual results that are not in expected results if configured
203
+ ignore_keys = frozenset(
204
+ box_test.ignore_fields_in_destination_compare.keys()
205
+ )
197
206
  for i, actual_result in enumerate(output_result):
198
207
  if i < len(box_test.output):
199
208
  actual_result.remove_fields_not_in_expected(box_test.output[i])
200
209
 
201
210
  if self.create_comparison_sheet:
202
211
  self.comp_obj.create_comparison_sheet(output_result, box_test)
203
- assert sorted(output_result) == sorted(box_test.output)
212
+ if ignore_keys:
213
+ stripped_actual = [
214
+ Results(
215
+ self._strip_ignored_fields_for_destination_compare(
216
+ row.data, ignore_keys
217
+ )
218
+ )
219
+ for row in output_result
220
+ ]
221
+ stripped_expected = [
222
+ Results(
223
+ self._strip_ignored_fields_for_destination_compare(
224
+ row.data, ignore_keys
225
+ )
226
+ )
227
+ for row in box_test.output
228
+ ]
229
+ assert sorted(stripped_actual) == sorted(stripped_expected)
230
+ else:
231
+ assert sorted(output_result) == sorted(box_test.output)
204
232
  _LOGGER.info(f"Output check passed: {box_test.name}")
205
233
  _LOGGER.debug("Received: \n%s", str(output_result))
206
234
  _LOGGER.debug("Expected: \n%s", str(box_test.output))
@@ -243,7 +271,7 @@ class SPL2TestRunner:
243
271
  return copy.deepcopy(d)
244
272
 
245
273
  @staticmethod
246
- def _strip_ignored_fields_for_splunk_compare(
274
+ def _strip_ignored_fields_for_destination_compare(
247
275
  row: dict, ignore_keys: frozenset
248
276
  ) -> dict:
249
277
  if not ignore_keys:
@@ -260,7 +288,12 @@ class SPL2TestRunner:
260
288
  ) from e
261
289
 
262
290
  def _check_splunk_results(self, box_test, destination_rows):
263
- """Use the same destination rows as ``run_box_test`` (from ``get_job_results``), not ``job.result``."""
291
+ """Validate HEC round-trip rows against the box test (CIM or TA).
292
+
293
+ ``destination_rows`` must be the same list returned from
294
+ ``get_job_results(job)["destination"]`` as used for reduction stats and
295
+ non-Splunk output assertions — not ``job.result``.
296
+ """
264
297
  if self.check_splunk_results == "CIM":
265
298
  if not box_test.cim_fields:
266
299
  pytest.skip("CIM output not available for this test")
@@ -279,9 +312,27 @@ class SPL2TestRunner:
279
312
  if not box_test.output:
280
313
  pytest.skip("TA expected output not available for this test")
281
314
 
282
- ignore_spec = box_test.ignore_fields_in_splunk_check
315
+ ignore_spec = box_test.ignore_fields_in_destination_compare
283
316
  ignore_keys = frozenset(ignore_spec.keys())
284
- for event, expected in zip(destination_rows, box_test.output):
317
+ assert len(destination_rows) == len(box_test.output), (
318
+ f"TA Splunk check for {box_test.name!r}: "
319
+ f"got {len(destination_rows)} destination row(s) but "
320
+ f"{len(box_test.output)} expected output(s); zip would hide a mismatch."
321
+ )
322
+
323
+ def _raw_sort_key(row):
324
+ raw = row.data.get("_raw") if hasattr(row, "data") else row.get("_raw")
325
+ if isinstance(raw, str):
326
+ try:
327
+ raw = json.loads(raw)
328
+ except (ValueError, TypeError):
329
+ pass
330
+ return json.dumps(raw, sort_keys=True, default=str)
331
+
332
+ for event, expected in zip(
333
+ sorted(destination_rows, key=_raw_sort_key),
334
+ sorted(box_test.output, key=_raw_sort_key),
335
+ ):
285
336
  processed_event = self._splunk_process_event(
286
337
  box_test, self._row_as_dict(event)
287
338
  )
@@ -290,15 +341,19 @@ class SPL2TestRunner:
290
341
  # dropping keys not present on the expected row before asserting.
291
342
  actual_dict = dict(processed_event)
292
343
  Results._remove_extra_fields(actual_dict, expected_data)
293
- actual_cmp = self._strip_ignored_fields_for_splunk_compare(
344
+ actual_cmp = self._strip_ignored_fields_for_destination_compare(
294
345
  actual_dict, ignore_keys
295
346
  )
296
- expected_cmp = self._strip_ignored_fields_for_splunk_compare(
347
+ expected_cmp = self._strip_ignored_fields_for_destination_compare(
297
348
  expected_data, ignore_keys
298
349
  )
299
350
  assert actual_cmp == expected_cmp, (
300
- f"TA Splunk compare mismatch for {box_test.name}; "
301
- f"ignored_fields={ignore_spec!r}"
351
+ f"TA destination compare mismatch for {box_test.name}; "
352
+ f"ignore_fields_in_destination_compare={ignore_spec!r}"
302
353
  )
303
354
  else:
304
- return
355
+ _LOGGER.warning(
356
+ "Unknown check_splunk_results value %r — expected 'CIM' or 'TA'. "
357
+ "No Splunk validation performed.",
358
+ self.check_splunk_results,
359
+ )
@@ -13,16 +13,18 @@
13
13
  # limitations under the License.
14
14
 
15
15
  import logging
16
+ import time
16
17
  import uuid
17
18
  from typing import Optional
18
19
 
19
20
  import requests
21
+ import urllib3
20
22
  import splunklib.client as client
21
23
  import splunklib.results as results
22
-
23
- import time
24
-
25
24
  from requests.auth import HTTPBasicAuth
25
+ import json
26
+
27
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
26
28
 
27
29
  _LOGGER = logging.getLogger(__name__)
28
30
 
@@ -82,10 +84,12 @@ class SplunkHandler:
82
84
  def _raw_as_event_string(raw):
83
85
  """Normalize pipeline `_raw` for HEC JSON `event` (must be a string).
84
86
 
85
- ``str`` is passed through ``str()``; ``bytes`` are decoded as UTF-8 with
86
- replacement on invalid sequences (avoids :class:`UnicodeDecodeError` for
87
- odd binary payloads; prefer valid UTF-8 log text in normal use).
87
+ ``dict`` is serialized as valid JSON (double-quoted) so that Splunk's
88
+ field extractions (e.g. GCP TA KV_MODE=json) can parse it correctly.
89
+ ``bytes`` are decoded as UTF-8. Everything else uses ``str()``.
88
90
  """
91
+ if isinstance(raw, dict):
92
+ return json.dumps(raw) # {"data": {...}} - valid JSON
89
93
  if isinstance(raw, bytes):
90
94
  return raw.decode("utf-8", errors="replace")
91
95
  return str(raw)
@@ -165,4 +169,11 @@ class SplunkHandler:
165
169
  ),
166
170
  verify=False,
167
171
  )
172
+ if response.status_code == 409:
173
+ _LOGGER.info(
174
+ "Index '%s' already exists — skipping creation.",
175
+ self.splunk_config["index"],
176
+ )
177
+ return
178
+ response.raise_for_status()
168
179
  return response.json()