diff --git a/.claude-plugin/plugins/axiom/bin/xcprof b/.claude-plugin/plugins/axiom/bin/xcprof index a0143dea..714eeef8 100755 Binary files a/.claude-plugin/plugins/axiom/bin/xcprof and b/.claude-plugin/plugins/axiom/bin/xcprof differ diff --git a/tools/xcprof/analyze.go b/tools/xcprof/analyze.go index f5167e14..2d50089f 100644 --- a/tools/xcprof/analyze.go +++ b/tools/xcprof/analyze.go @@ -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: diff --git a/tools/xcprof/analyze_test.go b/tools/xcprof/analyze_test.go index e87e240c..4dab842a 100644 --- a/tools/xcprof/analyze_test.go +++ b/tools/xcprof/analyze_test.go @@ -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) diff --git a/tools/xcprof/cmd_analyze.go b/tools/xcprof/cmd_analyze.go index 37e56bf7..58db50a0 100644 --- a/tools/xcprof/cmd_analyze.go +++ b/tools/xcprof/cmd_analyze.go @@ -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, diff --git a/tools/xcprof/cmd_record.go b/tools/xcprof/cmd_record.go index 9a254277..d6ca444f 100644 --- a/tools/xcprof/cmd_record.go +++ b/tools/xcprof/cmd_record.go @@ -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"}, diff --git a/tools/xcprof/network.go b/tools/xcprof/network.go new file mode 100644 index 00000000..3bc1eab8 --- /dev/null +++ b/tools/xcprof/network.go @@ -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 , four +// /) distinguished only by column POSITION, +// so we map cells to the schema's 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 's +// , a '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 . +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 , 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 +} diff --git a/tools/xcprof/network_test.go b/tools/xcprof/network_test.go new file mode 100644 index 00000000..46363b4f --- /dev/null +++ b/tools/xcprof/network_test.go @@ -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(`` + + `` + + `connection-serial` + + `rx-bytes` + + `100` + + ``) + 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(`` + + `start-time` + + `connection-serial`) + 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)) + } +} diff --git a/tools/xcprof/report.go b/tools/xcprof/report.go index f08f3593..7242e9fa 100644 --- a/tools/xcprof/report.go +++ b/tools/xcprof/report.go @@ -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]) +} diff --git a/tools/xcprof/testdata/network-connection-stat.xml b/tools/xcprof/testdata/network-connection-stat.xml new file mode 100644 index 00000000..54eb6cf5 --- /dev/null +++ b/tools/xcprof/testdata/network-connection-stat.xml @@ -0,0 +1,4 @@ + + +start-timeSample Timestart-timeconnection-serialConnection Serial Numberpacked-identifierprocessOwnerprocessdurationIntervaldurationinterfaceNetwork Interfacenetwork-interfaceprotocolProtocolnetwork-protocollocal-addressLocalsockaddrremote-addressRemotesockaddrdescriptionConnectionformatted-labelrx-packetsPackets Inevent-countrx-bytesBytes Innetwork-size-in-bytestx-packetsPackets Outevent-counttx-bytesBytes Outnetwork-size-in-bytesrx-dupsDups Receivednetwork-size-in-bytesrx-oooOut-of-ordernetwork-size-in-bytestx-retxRetransmittednetwork-size-in-bytesmin-rttMin Round Tripdurationaverage-rttAvg Round Tripduration0265691TODO1000000000bridge100udp43910402576 0528 0 859840020917804utun114299245Ethernettcp48214565766373179920 01524207239911506448 012428110000002208299378utun12tcp62396528156 13941169805053 144115188075855872 03101957660 13941169805053 72057594037927936 0118750015000007853568214565766894780944 015132094793881551376 07107269182531250118437501449125916 13941169805053 144115188075855872 03605274140 13941169805053 72057594037927936 09343750223Wi-Fi21510000000004083728214565765735711248 09890631708057862672 021187500028937500287597528214565764074897936 01861461193118515728 0302649965891162500019812500346818214565763688956432 09463185494629220880 01125000014750000456868214565767010845200 013890276855216407056 0110330568965625096093750129136 + diff --git a/tools/xcprof/testdata/network-toc.xml b/tools/xcprof/testdata/network-toc.xml new file mode 100644 index 00000000..884b2a00 --- /dev/null +++ b/tools/xcprof/testdata/network-toc.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + 6.000000 + Time limit reached + 16.0 (17F42) + Network + Deferred + 6 seconds + + + + +
+
+
+ + + diff --git a/tools/xcprof/toc_test.go b/tools/xcprof/toc_test.go index f8d57c08..1ec918f5 100644 --- a/tools/xcprof/toc_test.go +++ b/tools/xcprof/toc_test.go @@ -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) + } +} diff --git a/tools/xcprof/types.go b/tools/xcprof/types.go index caefd2d7..92e01d8d 100644 --- a/tools/xcprof/types.go +++ b/tools/xcprof/types.go @@ -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"` }