Skip to content

Commit b41633e

Browse files
authored
Merge pull request #13 from sipcapture/feature/sipstress-style-summary
Feature/sipstress style summary
2 parents 200aa38 + 07eb503 commit b41633e

24 files changed

Lines changed: 1089 additions & 137 deletions

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,10 @@ The current MVP implements:
2525
- TLS transports `l1` and `ln` (UAC or UAS; same codes as for TCP, plus TLS files as needed)
2626
- Global and per-user variable scopes
2727
- SIPp-style auth credentials via `-au` / `-ap` for challenged `401` / `407` request retries, inline `[authentication username=... password=...]`, and server-side `verifyauth`
28+
- Optional SIP identity for built-in UAC / `invite_media`: `-sip_from` (From before `;tag=`), `-sip_pai`, `-sip_provider`, repeatable `-sip_extra_header` (see `docs/compatibility.md` `[trunk_*]` keywords)
2829
- Concurrent call generation with rate limiting
2930
- Interactive terminal UI via `gossipper tui` / `gossipper -interactive` for launch presets and live runtime control
30-
- Basic statistics and JSON summary export
31+
- Basic statistics and JSON summary export (`-summary_json`), optional standalone **HTML** report (`-summary_html` or `gossipper report-html -in … -out …`; see `docs/summary-json.md`)
3132
- Named per-step RTD timers via `start_rtd` / `rtd`, aggregated into summary JSON
3233
- XML `counter` / `display` attributes aggregated into summary JSON as execution counts
3334
- Exported stats now include failure-class counters such as `timeout`, `unexpected_sip`, `transport_error`, `parse_error`, `scenario_error`, and `cancelled`

cmd/gossip/main.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"flag"
78
"fmt"
@@ -11,18 +12,25 @@ import (
1112
"os/signal"
1213
"runtime"
1314
"runtime/pprof"
15+
"strings"
1416
"syscall"
1517

1618
"github.com/sipcapture/gossipper/internal/cli"
1719
"github.com/sipcapture/gossipper/internal/launcher"
1820
"github.com/sipcapture/gossipper/internal/pcap2scenario"
21+
"github.com/sipcapture/gossipper/internal/reporthtml"
1922
"github.com/sipcapture/gossipper/internal/shell"
23+
"github.com/sipcapture/gossipper/internal/stats"
2024
templ "github.com/sipcapture/gossipper/internal/template"
2125
"github.com/sipcapture/gossipper/internal/tui"
2226
)
2327

2428
func main() {
2529
if err := run(os.Args[1:]); err != nil {
30+
if errors.Is(err, launcher.ErrHealthCheckFailed) {
31+
fmt.Fprintln(os.Stderr, err)
32+
os.Exit(2)
33+
}
2634
fmt.Fprintln(os.Stderr, err)
2735
os.Exit(1)
2836
}
@@ -39,6 +47,9 @@ func run(args []string) error {
3947
if len(args) > 0 && args[0] == "pcap2scenario" {
4048
return runPCAP2Scenario(args[1:])
4149
}
50+
if len(args) > 0 && args[0] == "report-html" {
51+
return runReportHTML(args[1:])
52+
}
4253
if len(args) > 0 && (args[0] == "shell" || args[0] == "cli") {
4354
return shell.Run(os.Stdin, os.Stdout, os.Stderr)
4455
}
@@ -53,6 +64,8 @@ func run(args []string) error {
5364
}
5465
return err
5566
}
67+
cfg.ToolVersion = GetShortVersionString()
68+
5669
if cfg.InfIndexFile != "" {
5770
indexPath, entries, err := templ.GenerateCSVIndex("", cfg.InfIndexFile, cfg.InfIndexField)
5871
if err != nil {
@@ -167,3 +180,32 @@ func runPCAP2Scenario(args []string) error {
167180

168181
return pcap2scenario.Run(fs.Arg(0), *outDir, *sipPort, *pcapLink)
169182
}
183+
184+
func runReportHTML(args []string) error {
185+
fs := flag.NewFlagSet("report-html", flag.ContinueOnError)
186+
inPath := fs.String("in", "", "input summary JSON (from gossipper -summary_json)")
187+
outPath := fs.String("out", "", "output HTML file path")
188+
fs.Usage = func() {
189+
fmt.Fprintf(fs.Output(), "usage: gossipper report-html -in summary.json -out report.html\n")
190+
fs.PrintDefaults()
191+
}
192+
if err := fs.Parse(args); err != nil {
193+
return err
194+
}
195+
if strings.TrimSpace(*inPath) == "" || strings.TrimSpace(*outPath) == "" {
196+
fs.Usage()
197+
return fmt.Errorf("report-html: -in and -out are required")
198+
}
199+
raw, err := os.ReadFile(*inPath)
200+
if err != nil {
201+
return fmt.Errorf("report-html: read -in: %w", err)
202+
}
203+
var s stats.Summary
204+
if err := json.Unmarshal(raw, &s); err != nil {
205+
return fmt.Errorf("report-html: parse JSON: %w", err)
206+
}
207+
if err := reporthtml.WriteFile(strings.TrimSpace(*outPath), s); err != nil {
208+
return fmt.Errorf("report-html: write -out: %w", err)
209+
}
210+
return nil
211+
}

docs/compatibility.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@ into no-ops or empty strings.
117117
| `[$n]` / `[$name]` | supported | Action and string variables |
118118
| `[dynamic_id]` | supported | Atomic per-message counter with `INT32` wraparound to mirror SIPp; supports `+/-offset` |
119119
| `[routes]` | supported | When a `recv` command uses `rrs="true"`, captured `Record-Route` headers are replayed as `Route` headers (reverse order) in subsequent rendered messages |
120+
| `[trunk_from]` | supported | Built-in UAC / `invite_media`: value before `;tag=` in `From`; default `gossip <sip:gossip@local_bind:port>`; CLI `-sip_from` |
121+
| `[trunk_pai]` | supported | Optional full header line `P-Asserted-Identity: …` plus CRLF; empty when `-sip_pai` unset |
122+
| `[trunk_provider]` | supported | Optional `X-provider: …` plus CRLF; empty when `-sip_provider` unset |
123+
| `[trunk_extra]` | supported | Optional extra headers after `Via` (each line ends with CRLF); repeatable CLI `-sip_extra_header` |
120124
| `[clock_tick]` | supported | Milliseconds elapsed since engine start; supports `+/-offset` |
121125
| `[sipp_version]` | supported | Renders runtime version string (defaults to `Gossipper` when not explicitly provided) |
122126
| `[tdmmap]` | partial | Stub: renders `0.0.0/0`; `-tdmmap` CLI flag is not parsed and per-call slot allocation is deferred |

docs/summary-json.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Summary JSON (`-summary_json`)
2+
3+
## Schema version
4+
5+
The `schema_version` field is set to `gossipper_summary_v1` when writing the file via `-summary_json`. Consumers should treat unknown schema versions as incompatible.
6+
7+
## Tool version
8+
9+
`tool_version` is populated from the running binary (same string as `gossipper -version` short line).
10+
11+
## Media QoS (RTCP)
12+
13+
When RTCP Receiver Reports are received during RTP sessions, the summary may include:
14+
15+
- `media.rtcp_reception_reports` — count of RFC 3550 reception report blocks.
16+
- `media.rtcp_max_fraction_lost` — maximum observed loss fraction in `0..1`.
17+
- `media.rtcp_max_jitter_ts` / `media.rtcp_avg_jitter_ts` — jitter in RTP timestamp units (not milliseconds).
18+
19+
## Health checks (CI)
20+
21+
Optional flags (evaluated when **`-summary_json`**, **`-summary_html`**, or any health threshold is set — the run finalizes a summary in that case):
22+
23+
- `-health_min_success_ratio` — fail if `success_ratio` is below this value (e.g. `0.99`).
24+
- `-health_max_failed_calls` — fail if `failed_calls` exceed this value; `0` means any failure fails the run. Default `-1` disables.
25+
- `-health_max_timeouts` — fail if `timeouts` exceed this value. Default `-1` disables.
26+
27+
On failure the process exits with code **2** (other errors use **1**). The JSON file still contains `health` and `findings` with failure reasons.
28+
29+
## HTML report
30+
31+
- **`-summary_html PATH`**: after a SIP run, writes the same finalized summary as a **standalone** UTF-8 HTML file (no CDN, works offline). Can be used **without** `-summary_json`.
32+
- **`gossipper report-html -in summary.json -out report.html`**: rebuild HTML from an existing summary JSON file.
33+
34+
See implementation in `internal/reporthtml`.

internal/cli/config.go

Lines changed: 78 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -28,34 +28,40 @@ const (
2828
)
2929

3030
type Config struct {
31-
ScenarioFile string
32-
ScenarioName string
33-
Service string
34-
Transport string
35-
LocalIP string
36-
LocalPort int
37-
RemoteHost string
38-
RemotePort int
39-
AuthUsername string
40-
AuthPassword string
41-
Rate float64
42-
RateScale float64
43-
RateIncrease float64
44-
RateIncreaseStep time.Duration
45-
RateMax float64
46-
MaxReconnect int
47-
ReconnectSleep time.Duration
48-
ReconnectClose bool
49-
BaseCSeq int
50-
TotalCalls int
51-
MaxConcurrent int
52-
MaxSockets int
53-
Users int
54-
DefaultPause time.Duration
55-
DefaultRecvTO time.Duration
56-
RecvBYEFloorTO time.Duration // minimum mandatory recv BYE when scenario timeout omitted; 0=off
57-
GlobalTimeout time.Duration
58-
SummaryJSON string
31+
ScenarioFile string
32+
ScenarioName string
33+
Service string
34+
Transport string
35+
LocalIP string
36+
LocalPort int
37+
RemoteHost string
38+
RemotePort int
39+
AuthUsername string
40+
AuthPassword string
41+
Rate float64
42+
RateScale float64
43+
RateIncrease float64
44+
RateIncreaseStep time.Duration
45+
RateMax float64
46+
MaxReconnect int
47+
ReconnectSleep time.Duration
48+
ReconnectClose bool
49+
BaseCSeq int
50+
TotalCalls int
51+
MaxConcurrent int
52+
MaxSockets int
53+
Users int
54+
DefaultPause time.Duration
55+
DefaultRecvTO time.Duration
56+
RecvBYEFloorTO time.Duration // minimum mandatory recv BYE when scenario timeout omitted; 0=off
57+
GlobalTimeout time.Duration
58+
SummaryJSON string
59+
SummaryHTML string
60+
// ToolVersion is set by the main package (not a CLI flag) for JSON export.
61+
ToolVersion string
62+
HealthMinSuccessRatio float64
63+
HealthMaxFailedCalls int
64+
HealthMaxTimeouts int
5965
TraceMessages bool
6066
TraceShortMsg bool
6167
TraceCounts bool
@@ -119,33 +125,41 @@ type Config struct {
119125

120126
// PCAPLinkLayer selects PCAP datalink decoding for play_pcap_* (-pcap-link).
121127
PCAPLinkLayer string
128+
129+
// SipFrom / SipPAI / SipProvider / SipExtraHeaders drive [trunk_*] keywords in built-in scenarios (see docs/compatibility.md).
130+
SipFrom string
131+
SipPAI string
132+
SipProvider string
133+
SipExtraHeaders []string
122134
}
123135

124136
func DefaultConfig() Config {
125137
return Config{
126-
ScenarioName: "uac",
127-
Service: "service",
128-
Transport: DefaultTransport,
129-
LocalIP: "0.0.0.0",
130-
AuthPassword: "password",
131-
Rate: DefaultRate,
132-
RateScale: 1.0,
133-
BaseCSeq: 1,
134-
TotalCalls: DefaultTotalCalls,
135-
MaxConcurrent: DefaultMaxConcurrent,
136-
Users: 1,
137-
DefaultPause: DefaultPauseDurationMS * time.Millisecond,
138-
DefaultRecvTO: DefaultRecvTimeout,
139-
StatsDumpPeriod: time.Second,
140-
RTTDumpFrequency: 200,
141-
TLSSkipVerify: true,
142-
IPField: -1,
143-
RTPCodec: "PCMU/8000",
144-
RTPFreqMs: 20,
145-
RTPChannels: 1,
146-
LogOTELProto: "grpc",
147-
LogBufferSize: 16384,
148-
LogLevel: "info",
138+
ScenarioName: "uac",
139+
Service: "service",
140+
Transport: DefaultTransport,
141+
LocalIP: "0.0.0.0",
142+
AuthPassword: "password",
143+
Rate: DefaultRate,
144+
RateScale: 1.0,
145+
BaseCSeq: 1,
146+
TotalCalls: DefaultTotalCalls,
147+
MaxConcurrent: DefaultMaxConcurrent,
148+
Users: 1,
149+
DefaultPause: DefaultPauseDurationMS * time.Millisecond,
150+
DefaultRecvTO: DefaultRecvTimeout,
151+
StatsDumpPeriod: time.Second,
152+
RTTDumpFrequency: 200,
153+
TLSSkipVerify: true,
154+
IPField: -1,
155+
RTPCodec: "PCMU/8000",
156+
RTPFreqMs: 20,
157+
RTPChannels: 1,
158+
LogOTELProto: "grpc",
159+
LogBufferSize: 16384,
160+
LogLevel: "info",
161+
HealthMaxFailedCalls: -1,
162+
HealthMaxTimeouts: -1,
149163
}
150164
}
151165

@@ -197,7 +211,7 @@ func Parse(args []string) (Config, error) {
197211
fs.PrintDefaults()
198212
}
199213
fs.StringVar(&cfg.ScenarioFile, "sf", cfg.ScenarioFile, "path to XML scenario file")
200-
fs.StringVar(&cfg.ScenarioName, "sn", cfg.ScenarioName, "built-in scenario name (uac, uas)")
214+
fs.StringVar(&cfg.ScenarioName, "sn", cfg.ScenarioName, "built-in scenario name (uac, uas, invite_media)")
201215
fs.StringVar(&cfg.Service, "s", cfg.Service, "service name used in templates")
202216
fs.StringVar(&cfg.Transport, "t", cfg.Transport, "transport: u1/un/ui, t1/tn, l1/ln; client TLS aliases cl/cln; server UDP s1/sn; server TLS sl")
203217
fs.StringVar(&cfg.LocalIP, "i", cfg.LocalIP, "local IP address")
@@ -207,6 +221,13 @@ func Parse(args []string) (Config, error) {
207221
fs.IntVar(&cfg.IPField, "ipfield", cfg.IPField, "alias for -ip_field (SIPp-compatible)")
208222
fs.StringVar(&cfg.AuthUsername, "au", cfg.AuthUsername, "authorization username for authentication challenges")
209223
fs.StringVar(&cfg.AuthPassword, "ap", cfg.AuthPassword, "authorization password for authentication challenges")
224+
fs.StringVar(&cfg.SipFrom, "sip_from", cfg.SipFrom, "SIP From value before ;tag= in built-in UAC scenarios (name-addr or URI); empty = gossip <sip:gossip@local_ip:local_port>")
225+
fs.StringVar(&cfg.SipPAI, "sip_pai", cfg.SipPAI, "P-Asserted-Identity value only (no header name); empty omits the header")
226+
fs.StringVar(&cfg.SipProvider, "sip_provider", cfg.SipProvider, "sets X-provider to this token; empty omits")
227+
fs.Func("sip_extra_header", "repeatable: one extra SIP header line \"Name: value\" after Via on first in-dialog requests in built-in UAC scenarios", func(s string) error {
228+
cfg.SipExtraHeaders = append(cfg.SipExtraHeaders, strings.TrimSpace(s))
229+
return nil
230+
})
210231
fs.Float64Var(&cfg.Rate, "r", cfg.Rate, "calls per second")
211232
fs.Float64Var(&cfg.RateScale, "rate_scale", cfg.RateScale, "interactive rate control step scale (SIPp-compatible)")
212233
fs.Float64Var(&cfg.RateIncrease, "rate_increase", 0, "change target cps by this amount every -rate_interval milliseconds")
@@ -222,6 +243,10 @@ func Parse(args []string) (Config, error) {
222243
fs.IntVar(&cfg.TotalCalls, "m", cfg.TotalCalls, "total calls to place (0 = unlimited until SIGINT or -timeout_global; stress/long-run)")
223244
fs.IntVar(&cfg.Users, "users", cfg.Users, "number of logical users for user-scoped variables")
224245
fs.StringVar(&cfg.SummaryJSON, "summary_json", cfg.SummaryJSON, "write final stats to JSON file")
246+
fs.StringVar(&cfg.SummaryHTML, "summary_html", cfg.SummaryHTML, "write final stats to a standalone HTML report (same data as -summary_json; can be used without JSON)")
247+
fs.Float64Var(&cfg.HealthMinSuccessRatio, "health_min_success_ratio", 0, "when >0 with -summary_json or -summary_html, fail run if success_ratio is lower (e.g. 0.95); exit code 2")
248+
fs.IntVar(&cfg.HealthMaxFailedCalls, "health_max_failed_calls", -1, "when >=0 with -summary_json or -summary_html, fail if failed_calls exceed this (0 means any failure fails); exit code 2")
249+
fs.IntVar(&cfg.HealthMaxTimeouts, "health_max_timeouts", -1, "when >=0 with -summary_json or -summary_html, fail if timeouts exceed this; exit code 2")
225250
fs.BoolVar(&cfg.TraceMessages, "trace_msg", cfg.TraceMessages, "trace sent and received SIP messages")
226251
fs.BoolVar(&cfg.TraceShortMsg, "trace_shortmsg", false, "trace sent and received messages as compact CSV")
227252
fs.BoolVar(&cfg.TraceCounts, "trace_counts", false, "write periodic SIP message counters as CSV")
@@ -729,6 +754,7 @@ func writeHelpPreamble(w io.Writer) {
729754
fmt.Fprintln(w, " gossipper tui full-screen launcher / runtime UI")
730755
fmt.Fprintln(w, " gossipper -interactive same as tui")
731756
fmt.Fprintln(w, " gossipper pcap2scenario ... PCAP → XML scenarios")
757+
fmt.Fprintln(w, " gossipper report-html ... summary JSON → standalone HTML report")
732758
fmt.Fprintln(w)
733759
fmt.Fprintln(w, "See also: docs/interactive-shell.md, docs/tui.md")
734760
fmt.Fprintln(w)

internal/cli/run_profile.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ type runSpec struct {
4545
HEPHomerLakeRTCP *bool `json:"hep_homer_lake_rtcp,omitempty"`
4646
SendMediaReport *bool `json:"send_media_report,omitempty"`
4747
SummaryJSON *string `json:"summary_json,omitempty"`
48+
SummaryHTML *string `json:"summary_html,omitempty"`
49+
SipFrom *string `json:"sip_from,omitempty"`
50+
SipPAI *string `json:"sip_pai,omitempty"`
51+
SipProvider *string `json:"sip_provider,omitempty"`
52+
SipExtraHeaders []string `json:"sip_extra_headers,omitempty"`
4853
TraceMessages *bool `json:"trace_msg,omitempty"`
4954
StatPrintPeriod *string `json:"stat_period,omitempty"`
5055
InjectionFile *string `json:"injection_file,omitempty"`
@@ -217,6 +222,21 @@ func applyRunSpec(cfg *Config, spec *runSpec, configDir string) error {
217222
if spec.SummaryJSON != nil {
218223
cfg.SummaryJSON = strings.TrimSpace(*spec.SummaryJSON)
219224
}
225+
if spec.SummaryHTML != nil {
226+
cfg.SummaryHTML = strings.TrimSpace(*spec.SummaryHTML)
227+
}
228+
if spec.SipFrom != nil {
229+
cfg.SipFrom = strings.TrimSpace(*spec.SipFrom)
230+
}
231+
if spec.SipPAI != nil {
232+
cfg.SipPAI = strings.TrimSpace(*spec.SipPAI)
233+
}
234+
if spec.SipProvider != nil {
235+
cfg.SipProvider = strings.TrimSpace(*spec.SipProvider)
236+
}
237+
if len(spec.SipExtraHeaders) > 0 {
238+
cfg.SipExtraHeaders = append([]string(nil), spec.SipExtraHeaders...)
239+
}
220240
if spec.TraceMessages != nil {
221241
cfg.TraceMessages = *spec.TraceMessages
222242
}

internal/engine/engine.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,15 @@ type Config struct {
115115
// PCAPLinkLayer selects the PCAP datalink decoder for play_pcap_* replay (and mirrors CLI -pcap-link).
116116
// Empty means auto (uses file DLT; LINUX_SLL2 is detected from the global header).
117117
PCAPLinkLayer string
118+
119+
// SipFrom is the SIP From header value before ";tag=" (name-addr or URI). Empty uses gossip@local.
120+
SipFrom string
121+
// SipPAI is the P-Asserted-Identity header value (without the header name).
122+
SipPAI string
123+
// SipProvider sets X-provider to this token (empty omits the header).
124+
SipProvider string
125+
// SipExtraHeaders are full header lines "Name: value" appended after Via on the first request (repeatable -sip_extra_header).
126+
SipExtraHeaders []string
118127
}
119128

120129
type Engine struct {
@@ -1452,6 +1461,7 @@ func (e *Engine) executeCall(
14521461
BasePath: scen.BasePath,
14531462
InjectionFile: e.cfg.InjectionFile,
14541463
}
1464+
applySIPIdentityKeywords(renderCtx.ExtraKeywords, e.cfg, localIP, localPort)
14551465
currentRemoteHost := remoteHost
14561466
currentRemoteIP := remoteHost
14571467
currentRemotePort := remotePort

0 commit comments

Comments
 (0)