mirror of
https://github.com/CharlesWiltgen/Axiom.git
synced 2026-09-20 19:58:20 +08:00
feat(xcprof): parse network family; report memory/energy as not_exportable
Parse the network-connection-stat export (socket-level connection stats) into per-connection totals — process, protocol, remote, bytes/packets in and out — aggregated by connection serial across interval rows. New network.go reuses the cpu-profile id/ref resolution but maps generic cells positionally against the schema's column mnemonics. Verified against real Xcode 26 exports: the memory (Allocations/Leaks) and macOS energy (Power Profiler) instruments are not surfaced by xctrace export at all (their data lives in the trace event store / the instrument is iOS-only), so the support matrix now reports them not_exportable with a pointer to Instruments.app — replacing the Phase 1 guessed schema names. Also fix the record network preset to use the Network Connections instrument (which yields network-connection-stat), not HTTP Traffic (URLSession only) — the same class as the cpu CPU-Profiler finding.
This commit is contained in:
Binary file not shown.
+40
-12
@@ -6,26 +6,50 @@ import (
|
||||
)
|
||||
|
||||
type familyDef struct {
|
||||
name string
|
||||
schemas []string
|
||||
name string
|
||||
schemas []string // exportable schemas that satisfy this family
|
||||
exportable bool // false: xctrace export can never surface this family's data
|
||||
note string // emitted when the family is reported not_exportable
|
||||
}
|
||||
|
||||
// families maps a diagnostic family to the export schemas that satisfy it.
|
||||
// Schema names are verified against real Xcode 26 exports (axiom-o4sg), NOT
|
||||
// guessed: cpu-profile and network-connection-stat are XML-exportable; the
|
||||
// memory (Allocations/Leaks) and macOS energy (Power Profiler) instruments store
|
||||
// their data in the trace's event store, which `xctrace export` does not surface
|
||||
// at all — so those families are categorically not_exportable, never a misleading
|
||||
// "partial, parsing arrives later" (which would never come true).
|
||||
// Two families are exportable:false for DIFFERENT reasons, and the distinction
|
||||
// is load-bearing for the future iOS-energy work (axiom-fmaw):
|
||||
// - memory: PERMANENT — Allocations/Leaks data lives in the .oa event store on
|
||||
// every platform; no xctrace export will ever surface it. Stays false.
|
||||
// - energy: PROVISIONAL — Power Profiler simply doesn't run on macOS, but on an
|
||||
// iOS device it produces data. When iOS energy parsing lands, energy flips to
|
||||
// exportable:true with schemas + drops the note (the host-platform gate moves
|
||||
// into supportMatrix, not this flag).
|
||||
var families = []familyDef{
|
||||
{"cpu", []string{"cpu-profile"}},
|
||||
{"memory", []string{"allocations", "leaks"}},
|
||||
{"network", []string{"http-traffic", "network-connections"}},
|
||||
{"energy", []string{"power", "energy-model", "location-energy-model"}},
|
||||
{"hangs", []string{"hangs", "microstackshots"}},
|
||||
{name: "cpu", schemas: []string{"cpu-profile"}, exportable: true},
|
||||
{name: "memory", exportable: false, // permanent — event store, never exportable
|
||||
note: "Allocations/Leaks data isn't available via xctrace export (it lives in the trace event store); open the trace in Instruments.app for memory analysis"},
|
||||
{name: "network", schemas: []string{"network-connection-stat"}, exportable: true},
|
||||
{name: "energy", exportable: false, // provisional — macOS-unsupported; iOS parsing is axiom-fmaw
|
||||
note: "Power Profiler is iOS/iPadOS-only and isn't exported on macOS; on-device energy parsing is a future, device-verified addition"},
|
||||
{name: "hangs", schemas: []string{"hangs", "microstackshots"}, exportable: true},
|
||||
}
|
||||
|
||||
// supportMatrix reports, per family, whether xcprof measured it. cpu is
|
||||
// available when samples parsed; a family whose schema is present but not yet
|
||||
// parsed by this version is `partial` with a note; absent schemas are
|
||||
// `not_present`. This is the honesty contract — silence never reads as "clean".
|
||||
func supportMatrix(toc *TOC, cpuSamples int) []FamilyStatus {
|
||||
// supportMatrix reports, per family, whether xcprof measured it. cpu/network are
|
||||
// available when their data parsed, `partial` when the table is present but
|
||||
// nothing parsed; non-exportable families (memory, macOS energy) are
|
||||
// `not_exportable` with a note pointing at Instruments.app; absent exportable
|
||||
// families are `not_present`. This is the honesty contract — silence never reads
|
||||
// as "clean", and "can't measure" never reads as "measured, nothing found".
|
||||
func supportMatrix(toc *TOC, cpuSamples, netConns int) []FamilyStatus {
|
||||
out := make([]FamilyStatus, 0, len(families))
|
||||
for _, fam := range families {
|
||||
if !fam.exportable {
|
||||
out = append(out, FamilyStatus{Family: fam.name, Status: statusNotExportable, Note: fam.note})
|
||||
continue
|
||||
}
|
||||
present := false
|
||||
for _, s := range fam.schemas {
|
||||
if toc.hasSchema(s) {
|
||||
@@ -38,6 +62,10 @@ func supportMatrix(toc *TOC, cpuSamples int) []FamilyStatus {
|
||||
out = append(out, FamilyStatus{Family: fam.name, Status: statusAvailable})
|
||||
case fam.name == "cpu" && present:
|
||||
out = append(out, FamilyStatus{Family: fam.name, Status: statusPartial, Note: "cpu-profile table present but no samples parsed"})
|
||||
case fam.name == "network" && present && netConns > 0:
|
||||
out = append(out, FamilyStatus{Family: fam.name, Status: statusAvailable})
|
||||
case fam.name == "network" && present:
|
||||
out = append(out, FamilyStatus{Family: fam.name, Status: statusPartial, Note: "network-connection-stat table present but no connections parsed"})
|
||||
case present:
|
||||
out = append(out, FamilyStatus{Family: fam.name, Status: statusPartial, Note: "schema present; parsing arrives in a later xcprof version"})
|
||||
default:
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAggregateHotFramesAttribution(t *testing.T) {
|
||||
samples, _ := parseCPUProfile(loadFixture(t, "cpu-profile.xml"))
|
||||
@@ -188,6 +191,44 @@ func TestBuildReportScopeDoesNotDowngradeSupport(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReportNetwork(t *testing.T) {
|
||||
// A network trace (TOC carries network-connection-stat) + the real stat
|
||||
// export: network must parse, the family flip to available, and the report
|
||||
// carry the aggregated connections.
|
||||
rep, err := buildReport(buildOpts{
|
||||
trace: "net.trace",
|
||||
tocBytes: loadFixture(t, "network-toc.xml"),
|
||||
netBytes: loadFixture(t, "network-connection-stat.xml"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildReport: %v", err)
|
||||
}
|
||||
if rep.Network == nil {
|
||||
t.Fatal("expected a network report")
|
||||
}
|
||||
if rep.Network.Connections != 13 {
|
||||
t.Errorf("network connections = %d, want 13", rep.Network.Connections)
|
||||
}
|
||||
var netStatus, memStatus string
|
||||
for _, f := range rep.Support {
|
||||
switch f.Family {
|
||||
case "network":
|
||||
netStatus = f.Status
|
||||
case "memory":
|
||||
memStatus = f.Status
|
||||
}
|
||||
}
|
||||
if netStatus != statusAvailable {
|
||||
t.Errorf("network support = %q, want available", netStatus)
|
||||
}
|
||||
if memStatus != statusNotExportable {
|
||||
t.Errorf("memory support = %q, want not_exportable", memStatus)
|
||||
}
|
||||
if !strings.Contains(renderMarkdown(rep), "## Network (13 connections)") {
|
||||
t.Error("markdown missing the Network section")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderMarkdownSectionOrder(t *testing.T) {
|
||||
rep, _ := buildReport(buildOpts{trace: "cpu.trace", tocBytes: loadFixture(t, "toc.xml"), cpuBytes: loadFixture(t, "cpu-profile.xml"), hangMS: 250})
|
||||
md := renderMarkdown(rep)
|
||||
|
||||
@@ -11,9 +11,15 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cpuProfileXPath targets run 1 — the same run parseTOC selects. If multi-run
|
||||
// selection is ever added, both must change together.
|
||||
// cpuProfileXPath / netStatXPath target run 1 — the same run parseTOC selects.
|
||||
// If multi-run selection is ever added, all must change together.
|
||||
const cpuProfileXPath = `/trace-toc/run[@number="1"]/data/table[@schema="cpu-profile"]`
|
||||
const netStatXPath = `/trace-toc/run[@number="1"]/data/table[@schema="network-connection-stat"]`
|
||||
|
||||
// netStatSchema is the exportable socket-statistics table (the "Network
|
||||
// Connections" instrument). Verified on Xcode 26 — NOT http-traffic, which the
|
||||
// Phase 1 family table guessed.
|
||||
const netStatSchema = "network-connection-stat"
|
||||
|
||||
// exportTOC and exportTable are indirected so tests can drive analysis from
|
||||
// fixtures without a real .trace.
|
||||
@@ -42,6 +48,7 @@ type buildOpts struct {
|
||||
trace string
|
||||
tocBytes []byte
|
||||
cpuBytes []byte
|
||||
netBytes []byte
|
||||
startMS int64
|
||||
endMS int64
|
||||
hangMS int64
|
||||
@@ -82,6 +89,19 @@ func buildReport(opts buildOpts) (AnalyzeReport, error) {
|
||||
return AnalyzeReport{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// Network is independent of the cpu/scope path: it aggregates its own table.
|
||||
var netConns int
|
||||
if toc.hasSchema(netStatSchema) && len(opts.netBytes) > 0 {
|
||||
net, nerr := parseNetworkStat(opts.netBytes, 15)
|
||||
if nerr != nil {
|
||||
return AnalyzeReport{}, nerr
|
||||
}
|
||||
netConns = net.Connections
|
||||
if net.Connections > 0 {
|
||||
rep.Network = &net
|
||||
}
|
||||
}
|
||||
// The support matrix is a trace-level inventory: base it on the full parsed
|
||||
// count, not the scoped window, so `--start-ms`/`--end-ms` that excludes all
|
||||
// samples doesn't misreport cpu as "partial — no samples parsed".
|
||||
@@ -92,7 +112,7 @@ func buildReport(opts buildOpts) (AnalyzeReport, error) {
|
||||
samples = scoped
|
||||
}
|
||||
rep.CPUSamples = len(samples)
|
||||
rep.Support = supportMatrix(toc, fullSampleCount)
|
||||
rep.Support = supportMatrix(toc, fullSampleCount, netConns)
|
||||
|
||||
// Resolve raw-address frames before aggregation so hot/user frames carry
|
||||
// names. No-op (no shell-out) when nothing needs symbolicating.
|
||||
@@ -233,6 +253,14 @@ func runAnalyze(out io.Writer, args []string) int {
|
||||
return 2
|
||||
}
|
||||
}
|
||||
var netBytes []byte
|
||||
if toc.hasSchema(netStatSchema) {
|
||||
netBytes, err = exportTable(ctx, trace, netStatXPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "analyze: export network-connection-stat:", err)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
symbolize := func(samples []Sample) symbolizeResult {
|
||||
return symbolizeSamples(ctx, samples, opts.dsym)
|
||||
@@ -241,6 +269,7 @@ func runAnalyze(out io.Writer, args []string) int {
|
||||
trace: trace,
|
||||
tocBytes: tocBytes,
|
||||
cpuBytes: cpuBytes,
|
||||
netBytes: netBytes,
|
||||
startMS: opts.startMS,
|
||||
endMS: opts.endMS,
|
||||
hangMS: opts.hang,
|
||||
|
||||
+16
-10
@@ -21,19 +21,25 @@ const defaultMaxDuration = "60s"
|
||||
// timeout, so trace finalization/saving isn't killed mid-write.
|
||||
const recordExecGrace = 120 * time.Second
|
||||
|
||||
// presets map a preset name to a verified-on-Xcode-26 instrument set. The CPU
|
||||
// family deliberately uses "CPU Profiler" (schema cpu-profile, which the
|
||||
// analyzer parses), NOT "Time Profiler" (schema time-profile/time-sample, which
|
||||
// analyze does not yet read) — ADR-002's table predates the Phase 1 finding
|
||||
// that the parser keys on cpu-profile. Names verified via `xctrace list
|
||||
// instruments`; do not edit from memory.
|
||||
// presets map a preset name to a verified-on-Xcode-26 instrument set. Two
|
||||
// instrument choices are deliberate and verified empirically (axiom-o4sg), NOT
|
||||
// from memory:
|
||||
// - cpu uses "CPU Profiler" (schema cpu-profile, which analyze parses), NOT
|
||||
// "Time Profiler" (time-profile/time-sample, unparsed).
|
||||
// - network uses "Network Connections" (schema network-connection-stat, which
|
||||
// analyze parses — socket-level, any process), NOT "HTTP Traffic" (cfnetwork
|
||||
// tables that only populate for URLSession traffic and analyze doesn't read).
|
||||
//
|
||||
// Allocations/Leaks stay in the memory/full presets so a user can open the
|
||||
// recording in Instruments.app, even though analyze can't export their data.
|
||||
// Names verified via `xctrace list instruments`; do not edit from memory.
|
||||
var presets = map[string][]string{
|
||||
"cpu": {"CPU Profiler"},
|
||||
"memory": {"Allocations", "Leaks"},
|
||||
"network": {"CPU Profiler", "HTTP Traffic"},
|
||||
"network": {"CPU Profiler", "Network Connections"},
|
||||
"energy": {"Power Profiler"},
|
||||
"full": {"CPU Profiler", "Allocations", "Leaks", "HTTP Traffic"},
|
||||
"full-ios": {"CPU Profiler", "Allocations", "Leaks", "HTTP Traffic", "Power Profiler"},
|
||||
"full": {"CPU Profiler", "Allocations", "Leaks", "Network Connections"},
|
||||
"full-ios": {"CPU Profiler", "Allocations", "Leaks", "Network Connections", "Power Profiler"},
|
||||
}
|
||||
|
||||
// presetNames is the stable display order for usage/error text.
|
||||
@@ -49,7 +55,7 @@ var presetNames = []string{"cpu", "memory", "network", "energy", "full", "full-i
|
||||
var presetFamilies = map[string][]string{
|
||||
"cpu": {"cpu"},
|
||||
"memory": {"memory"},
|
||||
"network": {"cpu", "network"}, // records CPU Profiler + HTTP Traffic
|
||||
"network": {"cpu", "network"}, // records CPU Profiler + Network Connections
|
||||
"energy": {"energy"},
|
||||
"full": {"cpu", "memory", "network"},
|
||||
"full-ios": {"cpu", "memory", "network", "energy"},
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The network-connection-stat table reports per-connection socket statistics
|
||||
// over 1-second intervals — one row per connection per interval. Unlike
|
||||
// cpu-profile, its rows use GENERIC element names (two <sockaddr>, four
|
||||
// <event-count>/<network-size-in-bytes>) distinguished only by column POSITION,
|
||||
// so we map cells to the schema's <col> mnemonics by index rather than by tag.
|
||||
// Values are deduplicated with the same id/ref scheme cpu-profile uses: the
|
||||
// first occurrence carries id="N" + content, later cells reference it with
|
||||
// ref="N". We register every id (recursively) in document order, then resolve.
|
||||
|
||||
// NetConnection is one socket connection, summed across its interval rows.
|
||||
type NetConnection struct {
|
||||
Process string `json:"process,omitempty"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
Interface string `json:"interface,omitempty"`
|
||||
Local string `json:"local,omitempty"`
|
||||
Remote string `json:"remote,omitempty"`
|
||||
RxBytes int64 `json:"rx_bytes"`
|
||||
TxBytes int64 `json:"tx_bytes"`
|
||||
RxPackets int64 `json:"rx_packets"`
|
||||
TxPackets int64 `json:"tx_packets"`
|
||||
Intervals int `json:"intervals"` // stat rows aggregated into this connection
|
||||
}
|
||||
|
||||
// NetworkReport summarizes the network-connection-stat table for one trace.
|
||||
// UnattributedRows counts interval rows that carried traffic but no connection
|
||||
// serial — so a shortfall in the totals is never silent (the honesty contract).
|
||||
type NetworkReport struct {
|
||||
Connections int `json:"connections"`
|
||||
TotalRxBytes int64 `json:"total_rx_bytes"`
|
||||
TotalTxBytes int64 `json:"total_tx_bytes"`
|
||||
UnattributedRows int `json:"unattributed_rows,omitempty"`
|
||||
TopByBytes []NetConnection `json:"top_by_bytes,omitempty"`
|
||||
}
|
||||
|
||||
type netStatResult struct {
|
||||
XMLName xml.Name `xml:"trace-query-result"`
|
||||
Cols []string `xml:"node>schema>col>mnemonic"`
|
||||
Rows []netStatRow `xml:"node>row"`
|
||||
}
|
||||
|
||||
type netStatRow struct {
|
||||
Cells []netCell `xml:",any"`
|
||||
}
|
||||
|
||||
// netCell is one value cell. Children captures nested elements (a <process>'s
|
||||
// <pid>, a <formatted-label>'s parts) so id registration can recurse and never
|
||||
// miss a definition that a later ref points at.
|
||||
type netCell struct {
|
||||
XMLName xml.Name
|
||||
ID string `xml:"id,attr"`
|
||||
Ref string `xml:"ref,attr"`
|
||||
Fmt string `xml:"fmt,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
Children []netCell `xml:",any"`
|
||||
}
|
||||
|
||||
// rcell is a resolved cell: fmt for display fields (protocol, addresses), value
|
||||
// (raw chardata) for exact counters, pid lifted from a nested <pid>.
|
||||
type rcell struct {
|
||||
fmt string
|
||||
value string
|
||||
pid int
|
||||
}
|
||||
|
||||
func toRcell(c netCell) rcell {
|
||||
r := rcell{fmt: c.Fmt, value: strings.TrimSpace(c.Value)}
|
||||
for _, ch := range c.Children {
|
||||
if ch.XMLName.Local == "pid" {
|
||||
r.pid = atoiSafe(strings.TrimSpace(ch.Value))
|
||||
if r.pid == 0 {
|
||||
r.pid = atoiSafe(ch.Fmt)
|
||||
}
|
||||
}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func atoiSafe(s string) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func atoi64(s string) int64 {
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// splitProcessName turns "curl-local (59752)" into "curl-local". The pid is read
|
||||
// separately from the nested <pid>, so a missing/odd suffix just leaves the
|
||||
// fmt as-is.
|
||||
func splitProcessName(fmtStr string) string {
|
||||
if i := strings.LastIndex(fmtStr, " ("); i >= 0 {
|
||||
return fmtStr[:i]
|
||||
}
|
||||
return fmtStr
|
||||
}
|
||||
|
||||
// parseNetworkStat resolves the network-connection-stat export and aggregates
|
||||
// interval rows into per-connection totals, returning the topN connections by
|
||||
// total bytes. A schema-only export (no rows) yields a valid empty report.
|
||||
func parseNetworkStat(data []byte, topN int) (NetworkReport, error) {
|
||||
var raw netStatResult
|
||||
if err := xml.Unmarshal(data, &raw); err != nil {
|
||||
return NetworkReport{}, fmt.Errorf("parse network-connection-stat: %w", err)
|
||||
}
|
||||
cols := raw.Cols
|
||||
idtab := map[string]rcell{}
|
||||
var register func(c netCell)
|
||||
register = func(c netCell) {
|
||||
if c.ID != "" {
|
||||
idtab[c.ID] = toRcell(c)
|
||||
}
|
||||
for _, ch := range c.Children {
|
||||
register(ch)
|
||||
}
|
||||
}
|
||||
// Pass 1: register every id across all rows. xctrace declares an id before any
|
||||
// ref to it, but a full pre-pass makes that ordering a non-assumption — a
|
||||
// forward ref (should Apple ever emit one) still resolves correctly.
|
||||
for _, row := range raw.Rows {
|
||||
for _, c := range row.Cells {
|
||||
register(c)
|
||||
}
|
||||
}
|
||||
resolve := func(c netCell) rcell {
|
||||
if c.Ref != "" {
|
||||
return idtab[c.Ref]
|
||||
}
|
||||
return toRcell(c)
|
||||
}
|
||||
|
||||
// Pass 2: resolve each row positionally and aggregate by connection serial.
|
||||
bySerial := map[string]*NetConnection{}
|
||||
order := make([]string, 0, len(raw.Rows))
|
||||
var unattributed int
|
||||
for _, row := range raw.Rows {
|
||||
rec := make(map[string]rcell, len(cols))
|
||||
for i, c := range row.Cells {
|
||||
if i >= len(cols) {
|
||||
break
|
||||
}
|
||||
rec[cols[i]] = resolve(c)
|
||||
}
|
||||
rxb := atoi64(rec["rx-bytes"].value)
|
||||
txb := atoi64(rec["tx-bytes"].value)
|
||||
rxp := atoi64(rec["rx-packets"].value)
|
||||
txp := atoi64(rec["tx-packets"].value)
|
||||
serial := rec["connection-serial"].fmt
|
||||
if serial == "" {
|
||||
// A row carrying real traffic but no serial can't be attributed —
|
||||
// count it so the totals' shortfall is visible, never silent.
|
||||
if rxb|txb|rxp|txp != 0 {
|
||||
unattributed++
|
||||
}
|
||||
continue
|
||||
}
|
||||
a := bySerial[serial]
|
||||
if a == nil {
|
||||
proc := rec["process"]
|
||||
a = &NetConnection{
|
||||
Process: splitProcessName(proc.fmt),
|
||||
PID: proc.pid,
|
||||
Protocol: rec["protocol"].fmt,
|
||||
Interface: rec["interface"].fmt,
|
||||
Local: rec["local-address"].fmt,
|
||||
Remote: rec["remote-address"].fmt,
|
||||
}
|
||||
bySerial[serial] = a
|
||||
order = append(order, serial)
|
||||
}
|
||||
a.RxBytes += rxb
|
||||
a.TxBytes += txb
|
||||
a.RxPackets += rxp
|
||||
a.TxPackets += txp
|
||||
a.Intervals++
|
||||
}
|
||||
|
||||
rep := NetworkReport{Connections: len(order), UnattributedRows: unattributed}
|
||||
conns := make([]NetConnection, 0, len(order))
|
||||
for _, s := range order {
|
||||
c := bySerial[s]
|
||||
rep.TotalRxBytes += c.RxBytes
|
||||
rep.TotalTxBytes += c.TxBytes
|
||||
conns = append(conns, *c)
|
||||
}
|
||||
sort.SliceStable(conns, func(i, j int) bool {
|
||||
return conns[i].RxBytes+conns[i].TxBytes > conns[j].RxBytes+conns[j].TxBytes
|
||||
})
|
||||
if topN > 0 && len(conns) > topN {
|
||||
conns = conns[:topN]
|
||||
}
|
||||
rep.TopByBytes = conns
|
||||
return rep, nil
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// loadNetFixture reads the trimmed real network-connection-stat export (16
|
||||
// interval rows over 13 connections, captured on macOS 26.5 / Instruments 16.0).
|
||||
func loadNetFixture(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile("testdata/network-connection-stat.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestParseNetworkStatAggregatesByConnection(t *testing.T) {
|
||||
// Expected values were computed independently from the fixture (see the
|
||||
// Python ground-truth pass in the Phase 2d work): 13 distinct connection
|
||||
// serials, summed rx/tx across each serial's interval rows.
|
||||
rep, err := parseNetworkStat(loadNetFixture(t), 15)
|
||||
if err != nil {
|
||||
t.Fatalf("parseNetworkStat: %v", err)
|
||||
}
|
||||
if rep.Connections != 13 {
|
||||
t.Errorf("Connections = %d, want 13", rep.Connections)
|
||||
}
|
||||
if rep.TotalRxBytes != 302474 {
|
||||
t.Errorf("TotalRxBytes = %d, want 302474", rep.TotalRxBytes)
|
||||
}
|
||||
if rep.TotalTxBytes != 4639 {
|
||||
t.Errorf("TotalTxBytes = %d, want 4639", rep.TotalTxBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNetworkStatTopTalkerResolvesRefs(t *testing.T) {
|
||||
// The hottest connection by bytes is our own curl-local fetch of apple.com's
|
||||
// CDN. Its process is carried by a ref to an earlier row's id; getting the
|
||||
// name + pid right proves cross-row ref resolution works (the same dedup the
|
||||
// cpu-profile parser handles).
|
||||
rep, err := parseNetworkStat(loadNetFixture(t), 15)
|
||||
if err != nil {
|
||||
t.Fatalf("parseNetworkStat: %v", err)
|
||||
}
|
||||
if len(rep.TopByBytes) == 0 {
|
||||
t.Fatal("TopByBytes is empty")
|
||||
}
|
||||
got := rep.TopByBytes[0]
|
||||
want := NetConnection{
|
||||
Process: "curl-local",
|
||||
PID: 59752,
|
||||
Protocol: "tcp4",
|
||||
Interface: "Ethernet",
|
||||
Local: "10.0.0.114:50479",
|
||||
Remote: "23.61.213.25:443",
|
||||
RxBytes: 264996,
|
||||
TxBytes: 589,
|
||||
RxPackets: 30,
|
||||
TxPackets: 7,
|
||||
Intervals: 1,
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("TopByBytes[0]\n got = %+v\nwant = %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNetworkStatTopNLimits(t *testing.T) {
|
||||
rep, err := parseNetworkStat(loadNetFixture(t), 3)
|
||||
if err != nil {
|
||||
t.Fatalf("parseNetworkStat: %v", err)
|
||||
}
|
||||
if len(rep.TopByBytes) != 3 {
|
||||
t.Errorf("TopByBytes length = %d, want 3 (limited)", len(rep.TopByBytes))
|
||||
}
|
||||
// Connections counts all distinct serials regardless of the top-N display cap.
|
||||
if rep.Connections != 13 {
|
||||
t.Errorf("Connections = %d, want 13", rep.Connections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNetworkStatCountsUnattributedTraffic(t *testing.T) {
|
||||
// A row with byte counters but no connection serial (sentinel) can't be
|
||||
// attributed to a connection; it must be counted, not silently dropped, so a
|
||||
// shortfall in the totals is visible.
|
||||
in := []byte(`<?xml version="1.0"?><trace-query-result><node xpath='x'>` +
|
||||
`<schema name="network-connection-stat">` +
|
||||
`<col><mnemonic>connection-serial</mnemonic></col>` +
|
||||
`<col><mnemonic>rx-bytes</mnemonic></col></schema>` +
|
||||
`<row><sentinel/><network-size-in-bytes id="1" fmt="100 Bytes">100</network-size-in-bytes></row>` +
|
||||
`</node></trace-query-result>`)
|
||||
rep, err := parseNetworkStat(in, 15)
|
||||
if err != nil {
|
||||
t.Fatalf("parseNetworkStat: %v", err)
|
||||
}
|
||||
if rep.Connections != 0 {
|
||||
t.Errorf("Connections = %d, want 0 (row had no serial)", rep.Connections)
|
||||
}
|
||||
if rep.UnattributedRows != 1 {
|
||||
t.Errorf("UnattributedRows = %d, want 1", rep.UnattributedRows)
|
||||
}
|
||||
if rep.TotalRxBytes != 0 {
|
||||
t.Errorf("TotalRxBytes = %d, want 0 (unattributed traffic is not summed into a connection)", rep.TotalRxBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNetworkStatEmptyTable(t *testing.T) {
|
||||
// A schema-only export (instrument recorded, no traffic) must parse to an
|
||||
// empty-but-valid report, never an error — the honesty contract distinguishes
|
||||
// "measured, nothing happened" from "couldn't measure".
|
||||
empty := []byte(`<?xml version="1.0"?><trace-query-result><node xpath='x'>` +
|
||||
`<schema name="network-connection-stat"><col><mnemonic>start-time</mnemonic></col>` +
|
||||
`<col><mnemonic>connection-serial</mnemonic></col></schema></node></trace-query-result>`)
|
||||
rep, err := parseNetworkStat(empty, 15)
|
||||
if err != nil {
|
||||
t.Fatalf("parseNetworkStat(empty): %v", err)
|
||||
}
|
||||
if rep.Connections != 0 || len(rep.TopByBytes) != 0 {
|
||||
t.Errorf("empty table: got Connections=%d Top=%d, want 0/0", rep.Connections, len(rep.TopByBytes))
|
||||
}
|
||||
}
|
||||
+33
-1
@@ -75,7 +75,24 @@ func renderMarkdown(r AnalyzeReport) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 6. (parse failures) / 7. (other families) collapse into Support above in Phase 1.
|
||||
// 6. Network (socket connections, when the table was exported with data)
|
||||
if r.Network != nil {
|
||||
n := r.Network
|
||||
fmt.Fprintf(&b, "\n## Network (%d connections)\n", n.Connections)
|
||||
fmt.Fprintf(&b, "- total: %s in · %s out\n", humanBytes(n.TotalRxBytes), humanBytes(n.TotalTxBytes))
|
||||
if n.UnattributedRows > 0 {
|
||||
fmt.Fprintf(&b, "- %d interval row(s) had traffic but no connection serial — not counted above\n", n.UnattributedRows)
|
||||
}
|
||||
if len(n.TopByBytes) > 0 {
|
||||
b.WriteString("| process | proto | remote | in | out |\n|---|---|---|---|---|\n")
|
||||
for _, c := range n.TopByBytes {
|
||||
fmt.Fprintf(&b, "| %s (%d) | %s | %s | %s | %s |\n",
|
||||
c.Process, c.PID, c.Protocol, c.Remote, humanBytes(c.RxBytes), humanBytes(c.TxBytes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 7. (other families) collapse into Support above.
|
||||
// 8. Notes / caveats
|
||||
if len(r.Notes) > 0 {
|
||||
b.WriteString("\n## Notes\n")
|
||||
@@ -85,3 +102,18 @@ func renderMarkdown(r AnalyzeReport) string {
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// humanBytes renders a byte count in binary units (KiB/MiB/GiB) for the
|
||||
// human-glanceable markdown; the JSON keeps the exact integer.
|
||||
func humanBytes(n int64) string {
|
||||
const unit = 1024
|
||||
if n < unit {
|
||||
return fmt.Sprintf("%d B", n)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for v := n / unit; v >= unit; v /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+26
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0"?>
|
||||
|
||||
<trace-toc>
|
||||
<run number="1">
|
||||
<info>
|
||||
<target>
|
||||
<device platform="macOS" model="Mac Studio" name="Mac Studio" os-version="26.5 (25F71)" uuid="CF383450-E70D-57EA-9E23-76E6791524B1"/>
|
||||
<process type="all" return-exit-status="0" name="(all processes)" pid="0"/>
|
||||
</target>
|
||||
<summary>
|
||||
<duration>6.000000</duration>
|
||||
<end-reason>Time limit reached</end-reason>
|
||||
<instruments-version>16.0 (17F42)</instruments-version>
|
||||
<template-name>Network</template-name>
|
||||
<recording-mode>Deferred</recording-mode>
|
||||
<time-limit>6 seconds</time-limit>
|
||||
</summary>
|
||||
</info>
|
||||
<data>
|
||||
<table schema="thread-info" documentation="Associates threads with their owning process."/>
|
||||
<table schema="network-connection-detected" documentation="Reports a newly detected connection."/>
|
||||
<table schema="network-connection-stat" documentation="Provides statistical information about a connection over a given time interval."/>
|
||||
<table schema="network-connection-update" documentation="Incremental per-connection deltas."/>
|
||||
</data>
|
||||
</run>
|
||||
</trace-toc>
|
||||
@@ -33,25 +33,52 @@ func TestParseTOCSchemas(t *testing.T) {
|
||||
|
||||
func TestSupportMatrix(t *testing.T) {
|
||||
toc, _ := parseTOC(loadFixture(t, "toc.xml"))
|
||||
// memory and energy are categorically not_exportable (their data never
|
||||
// reaches an xctrace-exportable table); network is genuinely absent from a
|
||||
// pure cpu trace, so not_present.
|
||||
want := map[string]string{
|
||||
"cpu": statusAvailable,
|
||||
"memory": statusNotPresent,
|
||||
"memory": statusNotExportable,
|
||||
"network": statusNotPresent,
|
||||
"energy": statusNotPresent,
|
||||
"energy": statusNotExportable,
|
||||
"hangs": statusNotPresent,
|
||||
}
|
||||
for _, f := range supportMatrix(toc, 21) {
|
||||
for _, f := range supportMatrix(toc, 21, 0) {
|
||||
if want[f.Family] != f.Status {
|
||||
t.Errorf("family %s = %q, want %q", f.Family, f.Status, want[f.Family])
|
||||
}
|
||||
// A not_exportable family must carry an explanatory note, never a silent status.
|
||||
if f.Status == statusNotExportable && f.Note == "" {
|
||||
t.Errorf("family %s is not_exportable but has no note", f.Family)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportMatrixCPUPartialWhenNoSamples(t *testing.T) {
|
||||
toc, _ := parseTOC(loadFixture(t, "toc.xml"))
|
||||
for _, f := range supportMatrix(toc, 0) {
|
||||
for _, f := range supportMatrix(toc, 0, 0) {
|
||||
if f.Family == "cpu" && f.Status != statusPartial {
|
||||
t.Errorf("cpu with 0 samples = %q, want partial", f.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportMatrixNetwork(t *testing.T) {
|
||||
// A trace whose TOC carries the network-connection-stat table: available once
|
||||
// connections parse, partial when the table is present but empty.
|
||||
toc := &TOC{Schemas: []string{"network-connection-stat"}}
|
||||
status := func(conns int) string {
|
||||
for _, f := range supportMatrix(toc, 0, conns) {
|
||||
if f.Family == "network" {
|
||||
return f.Status
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if got := status(13); got != statusAvailable {
|
||||
t.Errorf("network with 13 connections = %q, want available", got)
|
||||
}
|
||||
if got := status(0); got != statusPartial {
|
||||
t.Errorf("network with table present but 0 connections = %q, want partial", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ package main
|
||||
const (
|
||||
statusAvailable = "available" // exported, parsed, results present
|
||||
statusPartial = "partial" // present but only partially handled
|
||||
// statusNotExportable: schema present in the TOC but xctrace can't export it
|
||||
// (GUI may still show data). Phase 1 can't distinguish this from absence
|
||||
// without attempting the export; Phase 2 will report it. Defined now so the
|
||||
// status enum is stable for consumers (ADR-002 honesty contract).
|
||||
// statusNotExportable: the instrument's data cannot be surfaced by xctrace
|
||||
// export — either because it lives in the trace event store rather than an
|
||||
// exportable table (Allocations/Leaks), or because the instrument is
|
||||
// unsupported on the host platform (Power Profiler on macOS). Instruments.app
|
||||
// may still show it. Distinct from not_present (genuinely absent but, if
|
||||
// recorded, would be exportable).
|
||||
statusNotExportable = "not_exportable"
|
||||
statusNotPresent = "not_present" // instrument wasn't in the recording
|
||||
)
|
||||
@@ -56,6 +58,7 @@ type AnalyzeReport struct {
|
||||
HotFrames []HotFrame `json:"hot_frames,omitempty"`
|
||||
UserFrames []HotFrame `json:"user_frames,omitempty"`
|
||||
MainThread *MainThreadStats `json:"main_thread,omitempty"`
|
||||
Network *NetworkReport `json:"network,omitempty"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user