synapse-hub 2.1.1 ā 2.2.0
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.
- package/.agents/agents/orchestrator.md +7 -0
- package/.agents/rules/lessons-learned.md +1 -0
- package/.agents/skills/agent-orchestration-multi-agent-optimize/SKILL.md +1 -1
- package/.agents/skills/antigravity-test-sentinel/SKILL.md +1 -2
- package/.agents/skills/api-network-mocking-interception/SKILL.md +1 -1
- package/.agents/skills/api-spec-to-e2e-matrix/SKILL.md +11 -4
- package/.agents/skills/architecture-decision-records/SKILL.md +1 -1
- package/.agents/skills/auth-session-and-cookie-manager/SKILL.md +1 -1
- package/.agents/skills/backend-load-and-stress-patterns/SKILL.md +32 -11
- package/.agents/skills/bats-testing-patterns/SKILL.md +1 -1
- package/.agents/skills/clean-pom-crafter/SKILL.md +1 -4
- package/.agents/skills/code-refactoring-refactor-clean/SKILL.md +1 -1
- package/.agents/skills/db-state-verifier-and-seeder/SKILL.md +1 -1
- package/.agents/skills/e2e-testing-patterns/SKILL.md +1 -1
- package/.agents/skills/failure-autopsy-and-healing/SKILL.md +1 -3
- package/.agents/skills/file-and-report-validator/SKILL.md +1 -1
- package/.agents/skills/flaky-test-sentinel-and-quarantine/SKILL.md +1 -1
- package/.agents/skills/github-actions-templates/SKILL.md +1 -1
- package/.agents/skills/graphify-impact-analyzer/SKILL.md +1 -3
- package/.agents/skills/katalon-cli-runner/SKILL.md +1 -1
- package/.agents/skills/lam-script-generator/SKILL.md +1 -2
- package/.agents/skills/playwright-live-interaction/SKILL.md +1 -4
- package/.agents/skills/security-dast-sentinel/SKILL.md +1 -1
- package/.agents/skills/self-healing-locator/SKILL.md +1 -2
- package/.agents/skills/skillspector-security-guard/SKILL.md +115 -0
- package/.agents/skills/synthetic-test-data-generator/SKILL.md +1 -1
- package/.agents/skills/token-and-context-optimizer/SKILL.md +1 -1
- package/.agents/skills/visual-regression-sentinel/SKILL.md +1 -1
- package/.agents/skills/wcag-audit-patterns/SKILL.md +1 -1
- package/.agents/skills/web-vitals-and-performance-profiler/SKILL.md +1 -1
- package/AGENTS.md +36 -0
- package/README.md +140 -103
- package/package.json +1 -1
- package/skills_manifest.json +42 -29
- package/templates/PROJECT_AI_GUIDE_TEMPLATE.md +15 -1
- package/templates/ai_bridges/CLAUDE.md +31 -0
- package/templates/ai_bridges/copilot-instructions.md +25 -0
- package/templates/ai_bridges/cursor_synapse_qa.mdc +26 -0
- package/templates/ai_bridges/windsurfrules +25 -0
- package/templates/ci/github-actions-synapse.yml +28 -0
- package/templates/ci/gitlab-ci-synapse.yml +16 -0
- package/tools/TOOLS_RUNBOOK.md +203 -12
- package/tools/browser_helpers.js +2 -2
- package/tools/bump_version.py +8 -0
- package/tools/cli.py +74 -2
- package/tools/export_postman_collection.py +535 -218
- package/tools/generate_jmx_plan.py +672 -102
- package/tools/git_pre_commit_guard.py +21 -0
- package/tools/jtl_analyzer.py +474 -0
- package/tools/mcp_excel_server.ps1 +1 -1
- package/tools/mcp_server.py +1 -1
- package/tools/newman_analyzer.py +385 -0
- package/tools/setup_project.py +203 -18
- package/tools/skillspector.py +1141 -0
- package/tools/telemetry_correlator.py +351 -0
- package/tools/templates/jmeter-distributed/README.md +54 -0
- package/tools/templates/jmeter-distributed/docker-compose.yml +83 -0
- package/tools/update_sentinel.py +313 -0
|
@@ -13,6 +13,14 @@ import sys
|
|
|
13
13
|
import os
|
|
14
14
|
import subprocess
|
|
15
15
|
|
|
16
|
+
# Enforce UTF-8 stdout encoding for Windows console compatibility
|
|
17
|
+
if hasattr(sys.stdout, 'reconfigure'):
|
|
18
|
+
try:
|
|
19
|
+
sys.stdout.reconfigure(encoding='utf-8')
|
|
20
|
+
sys.stderr.reconfigure(encoding='utf-8')
|
|
21
|
+
except Exception:
|
|
22
|
+
pass
|
|
23
|
+
|
|
16
24
|
FORBIDDEN_PATHS = ["/Users/", "C:\\Workspace\\", "D:\\Workspace\\", "file:///c:", "file:///d:"]
|
|
17
25
|
|
|
18
26
|
def run_cmd(cmd):
|
|
@@ -135,6 +143,19 @@ def main():
|
|
|
135
143
|
|
|
136
144
|
errors_found = False
|
|
137
145
|
|
|
146
|
+
# 0. Hub Pollution Guard (When committing inside Synapse-Hub)
|
|
147
|
+
is_hub = os.path.exists("skills_manifest.json") and os.path.isdir(".agents")
|
|
148
|
+
if is_hub:
|
|
149
|
+
forbidden_hub_prefixes = ("Scripts/", "Keywords/", "Object Repository/", "Test Suites/", "Data Files/", "Include/")
|
|
150
|
+
polluting_files = [f for f in staged_files if any(f.startswith(p) for p in forbidden_hub_prefixes)]
|
|
151
|
+
if polluting_files:
|
|
152
|
+
print("ā [PRE-COMMIT BLOCKED] Target project business test files detected in Synapse-Hub:")
|
|
153
|
+
for pf in polluting_files:
|
|
154
|
+
print(f" - {pf}")
|
|
155
|
+
print("\nšØ CWD CONFUSION GUARD: You are attempting to commit target project code into Synapse-Hub!")
|
|
156
|
+
print(" Please unstage these files ('git reset HEAD') and commit them inside your target project workspace.")
|
|
157
|
+
errors_found = True
|
|
158
|
+
|
|
138
159
|
# 1. BOM Check
|
|
139
160
|
target_exts = (".groovy", ".tc", ".ts", ".glbl", ".md", ".py", ".json", ".ps1")
|
|
140
161
|
check_files = [f for f in staged_files if f.endswith(target_exts)]
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
tools/jtl_analyzer.py ā Enterprise Apache JMeter JTL Results Analyzer & CI/CD Gatekeeper
|
|
5
|
+
-----------------------------------------------------------------------------------------
|
|
6
|
+
Parses Apache JMeter JTL log results (CSV or XML format), calculates statistical percentiles
|
|
7
|
+
(P50, P90, P95, P99, Throughput, Error Rate), evaluates SLA compliance thresholds, and
|
|
8
|
+
enforces zero-tolerance Quality Gates in CI/CD pipelines with deterministic exit codes.
|
|
9
|
+
|
|
10
|
+
Features:
|
|
11
|
+
1. Universal Parsing: Supports standard JMeter CSV JTL and legacy XML JTL formats.
|
|
12
|
+
2. Statistical Engine: Calculates Min, Max, Average, P50 (Median), P90, P95, P99,
|
|
13
|
+
Throughput (TPS/RPS), Network KB/s, and Error Rate (%) per sampler and aggregate.
|
|
14
|
+
3. CI/CD Quality Gate: Evaluates user-defined SLA thresholds:
|
|
15
|
+
- Maximum acceptable P95 / P99 latency (ms)
|
|
16
|
+
- Maximum acceptable Average latency (ms)
|
|
17
|
+
- Maximum acceptable Error Rate (%)
|
|
18
|
+
Returns Exit Code 0 on PASS, Exit Code 1 on SLA breach.
|
|
19
|
+
4. Multi-Format Reporting:
|
|
20
|
+
- ANSI terminal summary table
|
|
21
|
+
- GitHub / GitLab Markdown report for Pull Request annotations
|
|
22
|
+
- Raw JSON export for downstream telemetry and APM indexing.
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
python tools/jtl_analyzer.py results.jtl --sla-p95 500 --max-error-rate 1.0
|
|
26
|
+
python tools/jtl_analyzer.py results.jtl --markdown report.md --json metrics.json
|
|
27
|
+
agent-qa analyze-jtl results.jtl --sla-p95 400
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
import sys
|
|
32
|
+
import csv
|
|
33
|
+
import json
|
|
34
|
+
import math
|
|
35
|
+
import argparse
|
|
36
|
+
import xml.etree.ElementTree as ET
|
|
37
|
+
from typing import Dict, List, Any, Optional, Tuple
|
|
38
|
+
|
|
39
|
+
# Enforce UTF-8 stdout encoding for Windows/macOS/Linux console compatibility
|
|
40
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
41
|
+
try:
|
|
42
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
43
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def calculate_percentile(sorted_values: List[float], percentile: float) -> float:
|
|
49
|
+
"""Calculates percentile from a pre-sorted list using linear interpolation."""
|
|
50
|
+
if not sorted_values:
|
|
51
|
+
return 0.0
|
|
52
|
+
k = (len(sorted_values) - 1) * (percentile / 100.0)
|
|
53
|
+
f = math.floor(k)
|
|
54
|
+
c = math.ceil(k)
|
|
55
|
+
if f == c:
|
|
56
|
+
return float(sorted_values[int(k)])
|
|
57
|
+
d0 = sorted_values[int(f)] * (c - k)
|
|
58
|
+
d1 = sorted_values[int(c)] * (k - f)
|
|
59
|
+
return float(d0 + d1)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SamplerStats:
|
|
63
|
+
"""Aggregates metrics for a single sampler label."""
|
|
64
|
+
def __init__(self, label: str):
|
|
65
|
+
self.label = label
|
|
66
|
+
self.samples: int = 0
|
|
67
|
+
self.errors: int = 0
|
|
68
|
+
self.elapsed_times: List[float] = []
|
|
69
|
+
self.total_bytes: int = 0
|
|
70
|
+
self.start_time: Optional[int] = None
|
|
71
|
+
self.end_time: Optional[int] = None
|
|
72
|
+
self.response_codes: Dict[str, int] = {}
|
|
73
|
+
|
|
74
|
+
def add_sample(self, elapsed: float, success: bool, timestamp: int, bytes_recv: int, response_code: str):
|
|
75
|
+
self.samples += 1
|
|
76
|
+
if not success:
|
|
77
|
+
self.errors += 1
|
|
78
|
+
self.elapsed_times.append(elapsed)
|
|
79
|
+
self.total_bytes += bytes_recv
|
|
80
|
+
|
|
81
|
+
if self.start_time is None or timestamp < self.start_time:
|
|
82
|
+
self.start_time = timestamp
|
|
83
|
+
if self.end_time is None or timestamp > self.end_time:
|
|
84
|
+
self.end_time = timestamp
|
|
85
|
+
|
|
86
|
+
code = str(response_code).strip() or "UNKNOWN"
|
|
87
|
+
self.response_codes[code] = self.response_codes.get(code, 0) + 1
|
|
88
|
+
|
|
89
|
+
def compute(self) -> Dict[str, Any]:
|
|
90
|
+
if not self.elapsed_times:
|
|
91
|
+
return {
|
|
92
|
+
"label": self.label,
|
|
93
|
+
"samples": 0,
|
|
94
|
+
"errors": 0,
|
|
95
|
+
"error_rate_pct": 0.0,
|
|
96
|
+
"avg_ms": 0.0,
|
|
97
|
+
"min_ms": 0.0,
|
|
98
|
+
"max_ms": 0.0,
|
|
99
|
+
"median_ms": 0.0,
|
|
100
|
+
"p90_ms": 0.0,
|
|
101
|
+
"p95_ms": 0.0,
|
|
102
|
+
"p99_ms": 0.0,
|
|
103
|
+
"throughput_tps": 0.0,
|
|
104
|
+
"kb_per_sec": 0.0,
|
|
105
|
+
"response_codes": self.response_codes,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
sorted_times = sorted(self.elapsed_times)
|
|
109
|
+
total_time = sum(sorted_times)
|
|
110
|
+
count = len(sorted_times)
|
|
111
|
+
avg_ms = total_time / count
|
|
112
|
+
min_ms = sorted_times[0]
|
|
113
|
+
max_ms = sorted_times[-1]
|
|
114
|
+
p50 = calculate_percentile(sorted_times, 50.0)
|
|
115
|
+
p90 = calculate_percentile(sorted_times, 90.0)
|
|
116
|
+
p95 = calculate_percentile(sorted_times, 95.0)
|
|
117
|
+
p99 = calculate_percentile(sorted_times, 99.0)
|
|
118
|
+
|
|
119
|
+
error_rate = (self.errors / count) * 100.0 if count > 0 else 0.0
|
|
120
|
+
|
|
121
|
+
# Duration in seconds
|
|
122
|
+
duration_sec = 0.0
|
|
123
|
+
if self.start_time is not None and self.end_time is not None:
|
|
124
|
+
duration_sec = max((self.end_time - self.start_time) / 1000.0, 0.001)
|
|
125
|
+
|
|
126
|
+
tps = count / duration_sec if duration_sec > 0 else 0.0
|
|
127
|
+
kb_sec = (self.total_bytes / 1024.0) / duration_sec if duration_sec > 0 else 0.0
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
"label": self.label,
|
|
131
|
+
"samples": count,
|
|
132
|
+
"errors": self.errors,
|
|
133
|
+
"error_rate_pct": round(error_rate, 2),
|
|
134
|
+
"avg_ms": round(avg_ms, 2),
|
|
135
|
+
"min_ms": round(min_ms, 2),
|
|
136
|
+
"max_ms": round(max_ms, 2),
|
|
137
|
+
"median_ms": round(p50, 2),
|
|
138
|
+
"p90_ms": round(p90, 2),
|
|
139
|
+
"p95_ms": round(p95, 2),
|
|
140
|
+
"p99_ms": round(p99, 2),
|
|
141
|
+
"throughput_tps": round(tps, 2),
|
|
142
|
+
"kb_per_sec": round(kb_sec, 2),
|
|
143
|
+
"response_codes": self.response_codes,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def parse_csv_jtl(filepath: str) -> Tuple[Dict[str, SamplerStats], SamplerStats]:
|
|
148
|
+
"""Parses standard comma/tab delimited JTL CSV file."""
|
|
149
|
+
per_sampler: Dict[str, SamplerStats] = {}
|
|
150
|
+
aggregate = SamplerStats("TOTAL AGGREGATE")
|
|
151
|
+
|
|
152
|
+
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
|
153
|
+
# Detect delimiter
|
|
154
|
+
first_line = f.readline()
|
|
155
|
+
if not first_line:
|
|
156
|
+
return per_sampler, aggregate
|
|
157
|
+
delimiter = "\t" if "\t" in first_line else ","
|
|
158
|
+
f.seek(0)
|
|
159
|
+
|
|
160
|
+
reader = csv.DictReader(f, delimiter=delimiter)
|
|
161
|
+
for row in reader:
|
|
162
|
+
try:
|
|
163
|
+
# Column names can vary slightly across JMeter versions
|
|
164
|
+
elapsed = float(row.get("elapsed") or row.get("Elapsed") or row.get("time") or 0)
|
|
165
|
+
label = row.get("label") or row.get("Label") or "Unknown"
|
|
166
|
+
|
|
167
|
+
success_val = row.get("success") or row.get("Success") or "true"
|
|
168
|
+
success = str(success_val).strip().lower() in ("true", "1")
|
|
169
|
+
|
|
170
|
+
ts_val = row.get("timeStamp") or row.get("Timestamp") or row.get("t") or 0
|
|
171
|
+
timestamp = int(float(ts_val))
|
|
172
|
+
|
|
173
|
+
bytes_val = row.get("bytes") or row.get("Bytes") or 0
|
|
174
|
+
bytes_recv = int(float(bytes_val))
|
|
175
|
+
|
|
176
|
+
resp_code = row.get("responseCode") or row.get("ResponseCode") or row.get("rc") or "200"
|
|
177
|
+
|
|
178
|
+
if label not in per_sampler:
|
|
179
|
+
per_sampler[label] = SamplerStats(label)
|
|
180
|
+
per_sampler[label].add_sample(elapsed, success, timestamp, bytes_recv, resp_code)
|
|
181
|
+
aggregate.add_sample(elapsed, success, timestamp, bytes_recv, resp_code)
|
|
182
|
+
except Exception:
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
return per_sampler, aggregate
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def parse_xml_jtl(filepath: str) -> Tuple[Dict[str, SamplerStats], SamplerStats]:
|
|
189
|
+
"""Parses legacy XML JTL file (<testResults> / <httpSample> / <sample>)."""
|
|
190
|
+
per_sampler: Dict[str, SamplerStats] = {}
|
|
191
|
+
aggregate = SamplerStats("TOTAL AGGREGATE")
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
tree = ET.parse(filepath)
|
|
195
|
+
root = tree.getroot()
|
|
196
|
+
for elem in root.findall(".//httpSample") + root.findall(".//sample"):
|
|
197
|
+
try:
|
|
198
|
+
elapsed = float(elem.attrib.get("t", 0))
|
|
199
|
+
label = elem.attrib.get("lb", "Unknown")
|
|
200
|
+
success = elem.attrib.get("s", "true").lower() == "true"
|
|
201
|
+
timestamp = int(elem.attrib.get("ts", 0))
|
|
202
|
+
bytes_recv = int(elem.attrib.get("by", 0))
|
|
203
|
+
resp_code = elem.attrib.get("rc", "200")
|
|
204
|
+
|
|
205
|
+
if label not in per_sampler:
|
|
206
|
+
per_sampler[label] = SamplerStats(label)
|
|
207
|
+
per_sampler[label].add_sample(elapsed, success, timestamp, bytes_recv, resp_code)
|
|
208
|
+
aggregate.add_sample(elapsed, success, timestamp, bytes_recv, resp_code)
|
|
209
|
+
except Exception:
|
|
210
|
+
continue
|
|
211
|
+
except Exception as e:
|
|
212
|
+
print(f"ā ļø XML Parse Error: {e}", file=sys.stderr)
|
|
213
|
+
|
|
214
|
+
return per_sampler, aggregate
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def parse_jtl(filepath: str) -> Tuple[Dict[str, SamplerStats], SamplerStats]:
|
|
218
|
+
"""Detects format and parses JTL file."""
|
|
219
|
+
if not os.path.exists(filepath):
|
|
220
|
+
raise FileNotFoundError(f"JTL file not found: {filepath}")
|
|
221
|
+
|
|
222
|
+
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
|
|
223
|
+
head = f.read(256).strip()
|
|
224
|
+
|
|
225
|
+
if head.startswith("<?xml") or head.startswith("<testResults"):
|
|
226
|
+
return parse_xml_jtl(filepath)
|
|
227
|
+
return parse_csv_jtl(filepath)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def evaluate_sla(
|
|
231
|
+
agg_stats: Dict[str, Any],
|
|
232
|
+
sla_p95: Optional[float] = None,
|
|
233
|
+
sla_p99: Optional[float] = None,
|
|
234
|
+
sla_avg: Optional[float] = None,
|
|
235
|
+
max_error_rate: Optional[float] = None
|
|
236
|
+
) -> Tuple[bool, List[str]]:
|
|
237
|
+
"""Evaluates computed aggregate metrics against SLA limits."""
|
|
238
|
+
violations: List[str] = []
|
|
239
|
+
|
|
240
|
+
if sla_p95 is not None and agg_stats["p95_ms"] > sla_p95:
|
|
241
|
+
violations.append(f"P95 Latency SLA Breached: {agg_stats['p95_ms']}ms > {sla_p95}ms")
|
|
242
|
+
|
|
243
|
+
if sla_p99 is not None and agg_stats["p99_ms"] > sla_p99:
|
|
244
|
+
violations.append(f"P99 Latency SLA Breached: {agg_stats['p99_ms']}ms > {sla_p99}ms")
|
|
245
|
+
|
|
246
|
+
if sla_avg is not None and agg_stats["avg_ms"] > sla_avg:
|
|
247
|
+
violations.append(f"Average Latency SLA Breached: {agg_stats['avg_ms']}ms > {sla_avg}ms")
|
|
248
|
+
|
|
249
|
+
if max_error_rate is not None and agg_stats["error_rate_pct"] > max_error_rate:
|
|
250
|
+
violations.append(f"Error Rate SLA Breached: {agg_stats['error_rate_pct']}% > {max_error_rate}%")
|
|
251
|
+
|
|
252
|
+
passed = (len(violations) == 0)
|
|
253
|
+
return passed, violations
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def format_table(per_sampler_data: List[Dict[str, Any]], agg_data: Dict[str, Any]) -> str:
|
|
257
|
+
"""Formats metrics into a clean CLI table."""
|
|
258
|
+
headers = ["Sampler Label", "Samples", "Errors (%)", "Avg (ms)", "Min", "Max", "P90", "P95", "P99", "TPS"]
|
|
259
|
+
col_w = [30, 9, 12, 10, 8, 8, 8, 8, 8, 10]
|
|
260
|
+
|
|
261
|
+
lines = []
|
|
262
|
+
header_str = " | ".join(h.ljust(col_w[i]) for i, h in enumerate(headers))
|
|
263
|
+
sep_str = "-+-".join("-" * col_w[i] for i in range(len(headers)))
|
|
264
|
+
|
|
265
|
+
lines.append(header_str)
|
|
266
|
+
lines.append(sep_str)
|
|
267
|
+
|
|
268
|
+
all_rows = list(per_sampler_data) + [agg_data]
|
|
269
|
+
for row in all_rows:
|
|
270
|
+
label = row["label"]
|
|
271
|
+
if len(label) > 28:
|
|
272
|
+
label = label[:25] + "..."
|
|
273
|
+
err_str = f"{row['errors']} ({row['error_rate_pct']}%)"
|
|
274
|
+
|
|
275
|
+
row_str = " | ".join([
|
|
276
|
+
label.ljust(col_w[0]),
|
|
277
|
+
str(row["samples"]).rjust(col_w[1]),
|
|
278
|
+
err_str.rjust(col_w[2]),
|
|
279
|
+
f"{row['avg_ms']:.1f}".rjust(col_w[3]),
|
|
280
|
+
f"{row['min_ms']:.0f}".rjust(col_w[4]),
|
|
281
|
+
f"{row['max_ms']:.0f}".rjust(col_w[5]),
|
|
282
|
+
f"{row['p90_ms']:.1f}".rjust(col_w[6]),
|
|
283
|
+
f"{row['p95_ms']:.1f}".rjust(col_w[7]),
|
|
284
|
+
f"{row['p99_ms']:.1f}".rjust(col_w[8]),
|
|
285
|
+
f"{row['throughput_tps']:.1f}".rjust(col_w[9]),
|
|
286
|
+
])
|
|
287
|
+
if row == agg_data:
|
|
288
|
+
lines.append(sep_str)
|
|
289
|
+
lines.append(row_str)
|
|
290
|
+
|
|
291
|
+
return "\n".join(lines)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def generate_markdown_report(
|
|
295
|
+
jtl_file: str,
|
|
296
|
+
per_sampler_data: List[Dict[str, Any]],
|
|
297
|
+
agg_data: Dict[str, Any],
|
|
298
|
+
passed: bool,
|
|
299
|
+
violations: List[str],
|
|
300
|
+
sla_info: Dict[str, Any]
|
|
301
|
+
) -> str:
|
|
302
|
+
"""Generates GitHub / GitLab Markdown report for Pull Request annotations."""
|
|
303
|
+
status_badge = "ā
**PASSED**" if passed else "ā **FAILED (SLA BREACHED)**"
|
|
304
|
+
|
|
305
|
+
lines = [
|
|
306
|
+
"# š Apache JMeter Performance & Load Test Report",
|
|
307
|
+
"",
|
|
308
|
+
f"- **Source Result File:** `{os.path.basename(jtl_file)}`",
|
|
309
|
+
f"- **Quality Gate Status:** {status_badge}",
|
|
310
|
+
f"- **Total Virtual Requests:** `{agg_data['samples']:,}`",
|
|
311
|
+
f"- **Total Errors:** `{agg_data['errors']}` ({agg_data['error_rate_pct']}%)",
|
|
312
|
+
f"- **Average Latency:** `{agg_data['avg_ms']} ms`",
|
|
313
|
+
f"- **95th Percentile (P95):** `{agg_data['p95_ms']} ms`",
|
|
314
|
+
f"- **Throughput:** `{agg_data['throughput_tps']} req/sec`",
|
|
315
|
+
"",
|
|
316
|
+
]
|
|
317
|
+
|
|
318
|
+
if violations:
|
|
319
|
+
lines.extend([
|
|
320
|
+
"### ā ļø SLA Violations Detected",
|
|
321
|
+
"",
|
|
322
|
+
])
|
|
323
|
+
for v in violations:
|
|
324
|
+
lines.append(f"- š“ `{v}`")
|
|
325
|
+
lines.append("")
|
|
326
|
+
|
|
327
|
+
lines.extend([
|
|
328
|
+
"### š Detailed Sampler Breakdown",
|
|
329
|
+
"",
|
|
330
|
+
"| Sampler Label | Samples | Errors | Error % | Avg (ms) | Min (ms) | Max (ms) | P90 (ms) | P95 (ms) | P99 (ms) | TPS |",
|
|
331
|
+
"| :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: |",
|
|
332
|
+
])
|
|
333
|
+
|
|
334
|
+
for row in per_sampler_data:
|
|
335
|
+
lines.append(
|
|
336
|
+
f"| `{row['label']}` | {row['samples']:,} | {row['errors']} | {row['error_rate_pct']}% | "
|
|
337
|
+
f"{row['avg_ms']} | {row['min_ms']} | {row['max_ms']} | {row['p90_ms']} | "
|
|
338
|
+
f"**{row['p95_ms']}** | {row['p99_ms']} | {row['throughput_tps']} |"
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# Aggregate row
|
|
342
|
+
lines.append(
|
|
343
|
+
f"| **TOTAL AGGREGATE** | **{agg_data['samples']:,}** | **{agg_data['errors']}** | **{agg_data['error_rate_pct']}%** | "
|
|
344
|
+
f"**{agg_data['avg_ms']}** | **{agg_data['min_ms']}** | **{agg_data['max_ms']}** | **{agg_data['p90_ms']}** | "
|
|
345
|
+
f"**{agg_data['p95_ms']}** | **{agg_data['p99_ms']}** | **{agg_data['throughput_tps']}** |"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
lines.extend([
|
|
349
|
+
"",
|
|
350
|
+
"---",
|
|
351
|
+
"*Report auto-generated by Synapse-Hub `jtl_analyzer.py` - Enterprise Performance Quality Gate.*"
|
|
352
|
+
])
|
|
353
|
+
|
|
354
|
+
return "\n".join(lines)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def main():
|
|
358
|
+
parser = argparse.ArgumentParser(
|
|
359
|
+
description="Apache JMeter JTL Results Analyzer & CI/CD SLA Quality Gatekeeper."
|
|
360
|
+
)
|
|
361
|
+
parser.add_argument("jtl_file", nargs="?", help="Path to JMeter results file (.jtl or .csv).")
|
|
362
|
+
parser.add_argument("--sla-p95", type=float, default=None, help="P95 Latency threshold in ms (fails if exceeded).")
|
|
363
|
+
parser.add_argument("--sla-p99", type=float, default=None, help="P99 Latency threshold in ms (fails if exceeded).")
|
|
364
|
+
parser.add_argument("--sla-avg", type=float, default=None, help="Average Latency threshold in ms (fails if exceeded).")
|
|
365
|
+
parser.add_argument("--max-error-rate", type=float, default=None, help="Maximum allowed error rate percentage (e.g. 1.0 = 1%).")
|
|
366
|
+
parser.add_argument("--markdown", "-m", help="Output path for Markdown summary report.")
|
|
367
|
+
parser.add_argument("--json", "-j", help="Output path for JSON summary metrics.")
|
|
368
|
+
parser.add_argument("--telemetry", help="Path to server telemetry CSV (timestamp, cpu_pct, memory_pct, db_pool_pct) for Full-Stack APM correlation.")
|
|
369
|
+
parser.add_argument("--sample", action="store_true", help="Generate and analyze a mock JTL dataset for verification.")
|
|
370
|
+
|
|
371
|
+
args = parser.parse_args()
|
|
372
|
+
|
|
373
|
+
# Handle sample verification mode
|
|
374
|
+
if args.sample or not args.jtl_file:
|
|
375
|
+
import tempfile
|
|
376
|
+
temp_jtl = os.path.join(tempfile.gettempdir(), "sample_jmeter_run.jtl")
|
|
377
|
+
with open(temp_jtl, "w", encoding="utf-8", newline="") as f:
|
|
378
|
+
writer = csv.writer(f)
|
|
379
|
+
writer.writerow(["timeStamp", "elapsed", "label", "responseCode", "responseMessage", "threadName", "dataType", "success", "failureMessage", "bytes", "sentBytes", "grpThreads", "allThreads", "URL", "Latency", "IdleTime", "Connect"])
|
|
380
|
+
|
|
381
|
+
# Generate 200 synthetic rows
|
|
382
|
+
import time
|
|
383
|
+
base_time = int(time.time() * 1000)
|
|
384
|
+
for i in range(150):
|
|
385
|
+
writer.writerow([base_time + (i * 100), 120 + (i % 50), "[GET] /api/v1/catalogs", "200", "OK", "Thread 1-1", "text", "true", "", 1024, 256, 10, 10, "http://api.local/catalogs", 115, 0, 15])
|
|
386
|
+
for i in range(45):
|
|
387
|
+
writer.writerow([base_time + (i * 100), 280 + (i % 80), "[POST] /api/v1/orders", "201", "Created", "Thread 1-2", "text", "true", "", 2048, 512, 10, 10, "http://api.local/orders", 270, 0, 20])
|
|
388
|
+
for i in range(5):
|
|
389
|
+
writer.writerow([base_time + (i * 100), 1500, "[POST] /api/v1/orders", "500", "Internal Error", "Thread 1-2", "text", "false", "DB Timeout", 512, 512, 10, 10, "http://api.local/orders", 1490, 0, 25])
|
|
390
|
+
|
|
391
|
+
args.jtl_file = temp_jtl
|
|
392
|
+
print(f"š¬ [SAMPLE MODE] Generated synthetic benchmark JTL: {temp_jtl}")
|
|
393
|
+
|
|
394
|
+
per_sampler_raw, agg_raw = parse_jtl(args.jtl_file)
|
|
395
|
+
if not per_sampler_raw:
|
|
396
|
+
print(f"ā ļø No sample records found in {args.jtl_file}", file=sys.stderr)
|
|
397
|
+
sys.exit(0)
|
|
398
|
+
|
|
399
|
+
per_sampler_data = [stats.compute() for stats in per_sampler_raw.values()]
|
|
400
|
+
agg_data = agg_raw.compute()
|
|
401
|
+
|
|
402
|
+
passed, violations = evaluate_sla(
|
|
403
|
+
agg_data,
|
|
404
|
+
sla_p95=args.sla_p95,
|
|
405
|
+
sla_p99=args.sla_p99,
|
|
406
|
+
sla_avg=args.sla_avg,
|
|
407
|
+
max_error_rate=args.max_error_rate
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
print("\n" + "=" * 90)
|
|
411
|
+
print(" APACHE JMETER PERFORMANCE RESULTS MATRIX (CI/CD QUALITY GATE)")
|
|
412
|
+
print("=" * 90)
|
|
413
|
+
print(format_table(per_sampler_data, agg_data))
|
|
414
|
+
print("=" * 90)
|
|
415
|
+
|
|
416
|
+
# Print SLA Verdict
|
|
417
|
+
print(f"\nšÆ [QUALITY GATE SLA VERDICT]")
|
|
418
|
+
if passed:
|
|
419
|
+
print("ā
ALL PERFORMANCE SLAs PASSED! Ready for deployment/merge.")
|
|
420
|
+
else:
|
|
421
|
+
print("ā PERFORMANCE SLA BREACHED:")
|
|
422
|
+
for v in violations:
|
|
423
|
+
print(f" š“ {v}")
|
|
424
|
+
|
|
425
|
+
# Optional Telemetry Correlation
|
|
426
|
+
telemetry_analysis = None
|
|
427
|
+
if args.telemetry and os.path.exists(args.telemetry):
|
|
428
|
+
from telemetry_correlator import parse_jtl_time_series, parse_server_metrics, run_correlation_analysis
|
|
429
|
+
jtl_buckets = parse_jtl_time_series(args.jtl_file, bucket_sec=5)
|
|
430
|
+
server_series = parse_server_metrics(args.telemetry, bucket_sec=5)
|
|
431
|
+
telemetry_analysis = run_correlation_analysis(jtl_buckets, server_series)
|
|
432
|
+
print("\n" + "=" * 90)
|
|
433
|
+
print(" FULL-STACK APM & SERVER TELEMETRY CORRELATION (RCA)")
|
|
434
|
+
print("=" * 90)
|
|
435
|
+
for k, v in telemetry_analysis["correlations"].items():
|
|
436
|
+
print(f" ⢠{k.replace('_', ' ').title():<25} : {v:+0.3f}")
|
|
437
|
+
for c in telemetry_analysis["root_causes"]:
|
|
438
|
+
print(f" [{c['severity']}] {c['component']}: {c['description']}")
|
|
439
|
+
print(f" š Recommendation: {c['recommendation']}")
|
|
440
|
+
|
|
441
|
+
# Export Markdown
|
|
442
|
+
if args.markdown:
|
|
443
|
+
md_content = generate_markdown_report(
|
|
444
|
+
args.jtl_file, per_sampler_data, agg_data, passed, violations, vars(args)
|
|
445
|
+
)
|
|
446
|
+
if telemetry_analysis:
|
|
447
|
+
from telemetry_correlator import format_rca_markdown
|
|
448
|
+
md_content += "\n\n" + format_rca_markdown(telemetry_analysis)
|
|
449
|
+
os.makedirs(os.path.dirname(os.path.abspath(args.markdown)) or ".", exist_ok=True)
|
|
450
|
+
with open(args.markdown, "w", encoding="utf-8") as f:
|
|
451
|
+
f.write(md_content)
|
|
452
|
+
print(f"š Markdown Report exported: {args.markdown}")
|
|
453
|
+
|
|
454
|
+
# Export JSON
|
|
455
|
+
if args.json:
|
|
456
|
+
export_payload = {
|
|
457
|
+
"source_jtl": os.path.abspath(args.jtl_file),
|
|
458
|
+
"passed": passed,
|
|
459
|
+
"violations": violations,
|
|
460
|
+
"aggregate": agg_data,
|
|
461
|
+
"samplers": per_sampler_data,
|
|
462
|
+
"telemetry_correlation": telemetry_analysis
|
|
463
|
+
}
|
|
464
|
+
os.makedirs(os.path.dirname(os.path.abspath(args.json)) or ".", exist_ok=True)
|
|
465
|
+
with open(args.json, "w", encoding="utf-8") as f:
|
|
466
|
+
json.dump(export_payload, f, indent=2)
|
|
467
|
+
print(f"š JSON Metrics exported: {args.json}")
|
|
468
|
+
|
|
469
|
+
# Exit code enforcement
|
|
470
|
+
sys.exit(0 if passed else 1)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
if __name__ == "__main__":
|
|
474
|
+
main()
|
|
@@ -160,7 +160,7 @@ while (($line = $inputReader.ReadLine()) -ne $null) {
|
|
|
160
160
|
result = @{
|
|
161
161
|
protocolVersion = "2024-11-05"
|
|
162
162
|
capabilities = @{ tools = @{} }
|
|
163
|
-
serverInfo = @{ name = "excel-qa-mcp-server"; version = "2.
|
|
163
|
+
serverInfo = @{ name = "excel-qa-mcp-server"; version = "2.2.0" }
|
|
164
164
|
}
|
|
165
165
|
}
|
|
166
166
|
[System.Console]::Out.WriteLine(($resp | ConvertTo-Json -Compress -Depth 10))
|