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:
Charles Wiltgen
2026-06-05 12:22:27 -07:00
parent 69d2baa811
commit 427d4922d1
12 changed files with 564 additions and 35 deletions
Binary file not shown.
+40 -12
View File
@@ -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:
+42 -1
View File
@@ -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)
+32 -3
View File
@@ -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
View File
@@ -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"},
+211
View File
@@ -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
}
+122
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+31 -4
View File
@@ -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)
}
}
+7 -4
View File
@@ -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"`
}