Skip to content

Latest commit

 

History

History
889 lines (576 loc) · 27 KB

File metadata and controls

889 lines (576 loc) · 27 KB

Netcap Performance Analysis

Date: April 2026 Scope: Full codebase analysis across all subsystems Findings: 53 performance issues, 4 correctness bugs, 7 critical bottlenecks


Implementation Status

Fix Status Verified
CSV writer missing mutex DONE go build, go test ./io/, go vet
Elastic sendBulk() race in Close() DONE go build, go vet
Round-robin counter race (atomic) DONE go build, go vet
sync.Pool for PacketContext DONE go build, go vet
DataFragments reader()io.MultiReader DONE go build
Regex compiled in loop (mail) DONE go build
CIDR blocks pre-parsed at init (filter) DONE go build, go test ./internal/filter/
Filter regex cache (MatchesPattern) DONE go build, go test ./internal/filter/
JSON writer: remove outer lock (inner suffices) DONE go build, go test ./io/
Elastic writer: exponential backoff (max 5 retries) DONE go build, go test ./io/
PacketBufferSize 100→1000, Workers CPU-proportional DONE go build, go test ./collector/
Cache layerTypeStr per layer iteration DONE go build
Bounded DNS cache (100K max, evict on overflow) DONE go build, go test ./resolvers/
DNS timeout reduced 10s→2s DONE go build
Bounded GeoIP cache (100K max, RWMutex) DONE go build, go test ./resolvers/
TCP flow .String() caching in decode() DONE go build
MAC OUI: normalize at insert, direct hex parse DONE go build, go test ./resolvers/
Replace deprecated ioutil → io/os DONE go build, go test ./io/
Cache reflect.TypeOf().String() in TCP decoder DONE go build
Replace deprecated ioutil in HTTP decoder DONE go build
GeoIP repr(): replace fmt.Sprintf with concat DONE go build
DNS: RWMutex for read path (RLock on cache hit) DONE go build, go test ./resolvers/

Table of Contents

  1. Executive Summary
  2. Core Pipeline Performance
  3. Decoder Subsystem
  4. Resolvers & Enrichment
  5. Internal Utilities
  6. Protobuf Schema
  7. Prioritized Recommendations
  8. Verification Strategy

1. Executive Summary

Netcap's architecture is well-designed for parallel packet processing, but several performance bottlenecks limit throughput under high-speed capture scenarios. The most critical issues fall into four categories:

  1. Lock contention — Coarse-grained mutexes serialize work that could run in parallel (protobuf writer, stream pool, JSON writer)
  2. Memory pressure — Missing object pooling, unbounded caches, and unnecessary data copies create GC overhead that scales linearly with throughput
  3. Hot-path allocations — Reflection, string formatting, and regex compilation in per-packet/per-stream code paths
  4. Correctness bugs — Race conditions and missing synchronization that can cause data corruption

Top 10 Issues by Impact

Rank Issue Location Category
1 CSV writer missing mutex io/csv_writer.go:38 Correctness
2 Elastic sendBulk() race condition io/elastic.go:220 Correctness
3 Round-robin counter race collector/collector.go:609 Correctness
4 StreamPool coarse RWMutex reassembly/stream_pool.go:33 Lock contention
5 Protobuf writer mutex per write io/protobuf.go:117 Lock contention
6 No PacketContext pooling collector/worker.go:77 Memory
7 Unbounded DNS cache resolvers/dns.go:32 Memory leak
8 Reflection in SSH unmarshaling decoder/stream/ssh/messages.go:374 Hot-path alloc
9 Double data copy in streams decoder/core/data_fragments.go:49 Memory
10 10-second blocking DNS timeout resolvers/dns.go:91 Blocking I/O

2. Core Pipeline Performance

2.1 Collector (collector/)

CRITICAL: No PacketContext Object Pooling

File: collector/worker.go:77

ctx := &types.PacketContext{}

A new PacketContext is allocated for every single packet processed. At high throughput (100K+ packets/sec), this creates millions of short-lived allocations per second, putting extreme pressure on the garbage collector.

Fix: Implement sync.Pool for PacketContext reuse:

var packetContextPool = sync.Pool{
    New: func() interface{} { return &types.PacketContext{} },
}

// In worker loop:
ctx := packetContextPool.Get().(*types.PacketContext)
*ctx = types.PacketContext{} // zero-reset
defer packetContextPool.Put(ctx)

CRITICAL: Channel Send Blocking

File: collector/collector.go:619-629

c.workers[idx] <- p  // Can block if worker buffer full

When worker buffers are full, the packet reading goroutine blocks, causing packet drops at the OS level. The timeout variant (handlePacketTimeout, lines 633-662) uses a 3-second timeout — far too long for real-time capture.

File: collector/config.go:40

PacketBufferSize: 100  // Default buffer per worker

A buffer of 100 packets is insufficient for burst traffic on modern networks.

Fix:

  • Increase default PacketBufferSize to at least 1000
  • Reduce timeout to 100ms with metrics on drops
  • Consider adaptive buffer sizing based on fill ratio

HIGH: Race Condition on Round-Robin Counter

File: collector/collector.go:608-610

c.next++
if c.next >= len(c.workers) {
    c.next = 0
}

The c.next counter is read and written by the packet dispatcher without synchronization. Multiple goroutines calling handlePacket concurrently will race on this value, causing uneven worker distribution.

Fix: Use atomic.AddInt64 or distribute packets from a single goroutine.


MEDIUM: Excessive Default Worker Count

File: collector/config.go:40

Workers: 1000

1000 goroutines by default is excessive for most systems. Each worker consumes stack memory and adds scheduling overhead. The optimal count depends on CPU core count and workload.

Fix: Default to runtime.NumCPU() * 4 or similar heuristic.


MEDIUM: time.Now() Called Multiple Times Per Packet

File: collector/worker.go:69, 136, 173

t := time.Now()  // Called 2-3 times per packet

time.Now() is a system call with measurable overhead (50-100ns). Called millions of times per second, this adds 3-5% CPU overhead.

Fix: Call once per worker iteration, reuse the value.


MEDIUM: String Conversions in Metrics Hot Path

File: collector/worker.go:139, 176

gopacketDecoderTime.WithLabelValues(layer.LayerType().String()).Set(...)
customDecoderTime.WithLabelValues(customDec.GetName()).Set(...)

.String() allocates a new string on every call. When metrics are enabled, this runs per-packet.

Fix: Cache label strings in a map keyed by layer type at init time.


2.2 Reassembly (reassembly/)

CRITICAL: Coarse-Grained StreamPool Lock

File: reassembly/stream_pool.go:30-39

type StreamPool struct {
    mu      sync.RWMutex  // Single lock for ALL connections
    conns   []*connection
    users   int
    // ...
}

All TCP stream lookups serialize through a single RWMutex. The getConnection() method (line 181-209) holds this lock while calling p.factory.New(), which may be expensive.

Fix: Shard the connection map by flow hash (e.g., 64 shards), each with its own lock. This allows parallel stream processing for independent flows.


CRITICAL: Unbounded Memory Growth

File: reassembly/stream_pool.go:41-51

func (p *StreamPool) grow() {
    // Doubles allocation exponentially with no upper limit
}

The page caches grow to handle peak workload and never shrink (noted in TODO at lines 107-110). A traffic spike causes permanently elevated memory usage.

Fix: Implement periodic shrinking or a high-water mark with gradual release.


MEDIUM: Long Lock Hold in Connection Processing

File: reassembly/connection.go:13

The per-connection sync.Mutex is held during the entire AssembleWithContext() operation (lines 208-296), which includes all packet processing for that stream direction.

Fix: Narrow the critical section to only protect shared state updates.


2.3 I/O Writers (io/)

CRITICAL: Protobuf Writer Mutex Per Message

File: io/protobuf.go:117-141

func (w *protoWriter) WriteProto(msg proto.Message) error {
    w.mu.Lock()
    defer w.mu.Unlock()
    // ... proto.Size(msg) called here too
}

Every protobuf message write acquires a mutex, and proto.Size(msg) is called inside the lock for performance tracking — duplicating serialization work.

Fix:

  • Buffer writes in a lock-free ring buffer, flush in batches
  • Move proto.Size() outside the lock or compute size during serialization
  • Consider per-decoder writers to eliminate cross-decoder contention

CRITICAL: CSV Writer Has No Mutex

File: io/csv_writer.go:38-46

The csvWriter struct has no sync.Mutex field, unlike protobuf and JSON writers. Concurrent writes from multiple worker goroutines will corrupt CSV output.

Fix: Add mutex protection matching the pattern in other writers.


CRITICAL: Elastic Writer Race Condition

File: io/elastic.go:220

Close() calls sendBulk() without holding the mutex, while Write() calls it with the mutex held. Concurrent shutdown during active writing causes a data race.

Fix: Acquire the mutex in Close() before calling sendBulk().


HIGH: Elastic Writer Infinite Retry

File: io/elastic.go:459-464

if err != nil {
    dur := 500 * time.Millisecond
    fmt.Println("failure indexing batch:", err)
    time.Sleep(dur)
    continue  // Infinite retry
}

Failed bulk indexing retries forever with a fixed 500ms delay. If Elasticsearch is down, this blocks the mutex indefinitely, effectively deadlocking the writer.

Fix: Implement exponential backoff with a maximum retry count. After exhausting retries, log the error and drop the batch.


HIGH: JSON Writer Holds Mutex During Marshaling

File: io/json_writer.go:115-135

The mutex is held while JSON marshaling occurs. JSON encoding is CPU-intensive and holds the lock far longer than necessary.

Fix: Marshal to a buffer outside the lock, then acquire the lock only for the write.


HIGH: Elastic Queue Slice Reallocation

File: io/elastic.go:414-447

w.queue = w.queue[w.processed:]  // Truncates, leaving dangling refs

Slice truncation doesn't release the underlying array, causing memory retention. Combined with make([]proto.Message, w.wc.BulkSize) reallocations, this creates unnecessary GC pressure.

Fix: Use a ring buffer or copy remaining items to a fresh slice.


HIGH: Blocking Channel Send in Chan Writer

File: io/chan_writer.go:181-184

func (w *chanProtoWriter) Write(p []byte) (int, error) {
    w.ch <- p  // Blocks if receiver is slow
    return len(p), nil
}

Unbuffered channel send blocks indefinitely if the receiver is slow or dead, causing goroutine deadlocks.

Fix: Use a buffered channel with a select/timeout fallback.


MEDIUM: Performance Tracking Overhead

File: io/protobuf.go:127-136

start := time.Now()
err := w.pWriter.putProto(msg)
duration := time.Since(start)
size := proto.Size(msg)  // Duplicates serialization analysis

When metrics are enabled, time.Now() and proto.Size() add ~15-20% overhead per write.

Fix: Estimate size from the serialized output length rather than calling proto.Size() separately.


3. Decoder Subsystem

3.1 Core Infrastructure

CRITICAL: Double Data Copy in Stream Fragments

File: decoder/core/data_fragments.go:39-51

func (d DataFragments) bytes() []byte {
    var b bytes.Buffer
    for _, dt := range d {
        b.Write(dt.Raw())  // Copy 1
    }
    return b.Bytes()  // Copy 2
}

func (d DataFragments) reader() io.Reader {
    return bytes.NewReader(d.bytes())  // Wraps copy
}

reader() calls bytes(), which copies all fragment data into a buffer, then wraps it in a reader. For large transfers (file downloads, etc.), this duplicates megabytes of data unnecessarily.

Fix: Implement io.MultiReader over fragments to stream without copying:

func (d DataFragments) reader() io.Reader {
    readers := make([]io.Reader, len(d))
    for i, dt := range d {
        readers[i] = bytes.NewReader(dt.Raw())
    }
    return io.MultiReader(readers...)
}

3.2 Stream Decoders

CRITICAL: Reflection-Based SSH Unmarshaling

File: decoder/stream/ssh/messages.go:374-520

The SSH decoder uses reflect.ValueOf(), reflect.Type(), and field iteration to marshal/unmarshal every SSH message. This is called for every SSH packet.

v := reflect.ValueOf(msg)
t := v.Type()
for i := 0; i < v.NumField(); i++ {
    field := t.Field(i)
    // ... reflection-based access
}

Fix: Replace with code-generated or hand-written marshal/unmarshal methods. This alone can yield 10-30x speedup for SSH decoding.


HIGH: reflect.TypeOf().String() in TCP/UDP Hot Paths

Files: decoder/stream/tcp/tcp_connection.go:501,507 and decoder/stream/udp/udp_stream.go:278

reflect.TypeOf(t.decoder).String()  // Called per-stream for metrics

Fix: Cache the type string at decoder initialization time.


HIGH: O(n) Linear Searches for Flow Tracking

File: decoder/stream/http/http_reader.go:265, 272

flowExists := slices.Contains(s.Flows, ident)        // O(n)
cidExists := slices.Contains(s.CommunityIDs, communityID)  // O(n)

With thousands of flows per software instance, this degrades to O(n) per lookup, making overall behavior O(n^2).

Fix: Use map[string]struct{} for O(1) lookups, convert to slice only for serialization.


HIGH: Slice Appends Without Pre-allocation

File: decoder/stream/service/service_probe.go:629, 718, 830, 906-907, 1018, 1025

res = append(res, b)  // In loops without capacity hint

Repeated appends without pre-allocated capacity cause O(n) reallocations as slices grow.

Fix: Pre-allocate with make([]T, 0, estimatedCap) where the upper bound is known.


HIGH: String Allocations in HTTP Parsing

File: decoder/stream/http/http_reader.go:219, 226, 376

pass = strings.Join(arr, "; ")           // Allocation per request
credentials.WriteCredentials(&types.Credentials{
    User: strings.Join(values, "; "),     // Another allocation
})

Multiple string allocations per HTTP request/response in the highest-volume protocol decoder.

Fix: Use strings.Builder with pre-allocated capacity, or avoid joins where the result is immediately serialized.


MEDIUM: Regex Compiled Inside Loop

File: decoder/stream/mail/mail_security.go:206

if matches := regexp.MustCompile(`d=([^\s;]+)`).FindStringSubmatch(dkim); len(matches) > 1 {

Regex is compiled on every DKIM parsing call instead of once at package init.

Fix: Move to package-level var:

var dkimDomainRegex = regexp.MustCompile(`d=([^\s;]+)`)

MEDIUM: Per-Packet Data Copy in TCP Reassembly

File: decoder/stream/tcp/tcp_connection.go:216-224

dataCpy := make([]byte, len(data))
l := copy(dataCpy, data)

Every reassembled TCP segment is copied before being sent on a channel, then decoders (HTTP, SSH, etc.) may copy it again.

Fix: Use reference counting or ensure the reassembly layer provides owned buffers that don't need copying.


MEDIUM: Repeated .String() Calls on Same Values

File: decoder/stream/tcp/tcp_connection.go:361-364, 418-421

ClientIP: t.client.Network().Src().String(),  // Called here
ServerIP: t.client.Network().Dst().String(),
// ... and again later for community ID calculation

Fix: Compute once, store in local variables.


MEDIUM: Inefficient Decoder Fallback Scan

File: decoder/stream/tcp/tcp_connection.go:442-493

When port-based lookup fails, all registered decoders are iterated with CanDecodeStream() checks. Some checks (e.g., HTTP) call bytes.Contains up to 9 times.

Fix: Order decoders by frequency (HTTP first), add early-exit heuristics, or use a trie on magic bytes.


LOW-MEDIUM: Deprecated ioutil Usage

File: decoder/stream/http/http_reader.go:297, 310

body, err := ioutil.ReadAll(res.Body)        // Deprecated
res.Body = ioutil.NopCloser(bytes.NewBuffer(body))  // Deprecated

Fix: Replace with io.ReadAll and io.NopCloser (available since Go 1.16).


4. Resolvers & Enrichment

4.1 DNS Resolution (resolvers/dns.go)

CRITICAL: Blocking 10-Second DNS Timeout

File: resolvers/dns.go:91-145

ctx, cancel := context.WithTimeout(context.TODO(), 10*time.Second)

Failed DNS lookups block the calling goroutine for up to 10 seconds. In the worst case, many unresolvable IPs can exhaust the worker pool.

Fix:

  • Reduce default timeout to 1-2 seconds
  • Implement async DNS with result channels
  • Add circuit breaker: after N consecutive failures, skip resolution for a cooldown period

CRITICAL: Unbounded DNS Cache

File: resolvers/dns.go:32-38

var dnsNamesDB = make(map[string]string)  // Grows forever

The DNS cache map grows without any eviction policy. Long-running captures will consume unbounded memory.

Fix: Use a bounded LRU cache (e.g., hashicorp/golang-lru) with a configurable max size (default 100K entries).


HIGH: Double-Locking on Cache Miss

File: resolvers/dns.go:115-127

The implementation acquires the lock to check the cache, releases it, performs DNS resolution, then acquires the lock again to store the result. This 2x lock acquisition pattern doubles contention on cache misses.

Fix: Use sync.Map or a sharded map to reduce contention, or hold the lock and use a singleflight.Group to deduplicate concurrent lookups for the same IP.


MEDIUM: IsPrivateIP() Linear CIDR Iteration

File: resolvers/dns.go:82-86

// Iterates through 17 CIDR blocks on every call
for _, cidr := range privateCIDRs {
    if cidr.Contains(ip) { return true }
}

Fix: Use a radix tree (e.g., netaddr package) or pre-computed bitmap for O(1) private IP checks.


4.2 GeoIP (resolvers/geoip.go)

HIGH: Unbounded sync.Map Cache

File: resolvers/geoip.go:37

sync.Map grows without eviction, similar to the DNS cache issue.

Fix: Bounded LRU cache.


MEDIUM: fmt.Sprintf Per Lookup

File: resolvers/geoip.go:110-119

func (g *GeoLocation) repr() string {
    return fmt.Sprintf("%s, %s, %s", g.City, g.Region, g.Country)
}

Called on every GeoIP lookup, fmt.Sprintf allocates a new string each time.

Fix: Cache the string representation or use strings.Builder.


4.3 MAC Vendor (resolvers/mac.go)

MEDIUM: strings.ToUpper() Per Packet

File: resolvers/mac.go:119

oui := strings.ToUpper(mac[:8])  // Allocates new string

Called for every packet when MAC lookup is enabled.

Fix: Normalize to uppercase at insertion time, not lookup time.


4.4 Service Lookup (resolvers/service.go)

MEDIUM: Port Range Expansion at Init

File: resolvers/service.go:148-166

Port ranges like 1-65535 are fully expanded into individual map entries during initialization, creating potentially 65K+ entries.

Fix: Store range objects and use binary search for lookups.


4.5 DPI (dpi/dpi.go)

MEDIUM: Double-Checked Locking Pattern

File: dpi/dpi.go:293-321

Uses atomic.Bool + RWMutex for a double-checked locking pattern that could be simplified with sync.Once.


MEDIUM: Temporary Wrapper Creation Per Query

File: dpi/dpi.go:327, 347, 367

New DPI wrapper instances are created just to query protocol lists, with incomplete cleanup (missing DestroyWrapper() calls noted in comments).

Fix: Use pre-initialized module instances or sync.Once initialization.


5. Internal Utilities

5.1 Filter Evaluation (internal/filter/)

HIGH: Reflection + Map Allocation Per Record

File: internal/filter/filter.go:204-223

v := reflect.ValueOf(protoMsg)
for i := 0; i < v.NumField(); i++ {
    env[field.Name] = convertFieldValue(fieldValue)
}

Every filter evaluation creates a map via reflection over the protobuf message. For high-volume filtering, this is a major bottleneck.

Fix: Generate typed accessor functions at filter compilation time. Cache field metadata.


HIGH: CIDR Blocks Parsed Every Call

File: internal/filter/helpers.go:56-86

IsPrivateIP() in the filter package parses 16 CIDR strings on every call rather than at init time.

Fix: Parse once at package init, store as []*net.IPNet.


HIGH: Regex Compiled Every Call

File: internal/filter/helpers.go:151-156

regexp.MatchString(pattern, input)  // Compiles pattern each time

Fix: Cache compiled regexes in a sync.Map keyed by pattern string.


MEDIUM: Performance Tracker Lock Contention

File: internal/performance/tracker.go:34-85

A single sync.RWMutex protects 12+ map fields. Every resolver lookup acquires this lock.

Fix: Use per-metric atomic counters or sharded maps.


6. Protobuf Schema

6.1 String Fields vs Enums

File: netcap.proto:234-256, 330-398

50+ fields use string type where enumerated values would be more efficient:

// Current (inefficient):
string ApplicationProto = 23;     // "HTTP", "TLS", etc.
string DetectedProtocolName = 30;  // "Facebook", "Google", etc.
string TransportProto = 20;
string NetworkProto = 21;
string LinkProto = 22;

// Recommended:
enum ApplicationProtocol { APP_UNKNOWN = 0; APP_HTTP = 1; APP_TLS = 2; ... }
ApplicationProtocol ApplicationProto = 23;

Enums encode as varints (1-2 bytes) vs strings (4-20+ bytes), reducing wire size 3-4x for these fields.


6.2 Field Number Optimization

File: netcap.proto:304-312

Field numbers jump non-sequentially (e.g., from 30 to 48-51). Protobuf field numbers 1-15 encode in 1 byte, while 16-2047 require 2 bytes. Frequently accessed fields should use numbers 1-15.

Fix: Reorder fields to place the most common ones in the 1-15 range. Note: this is a breaking change requiring data migration.


6.3 Repeated String Collections

File: netcap.proto:281

repeated string Applications = 28;  // String array

Fix: Use a packed enum array for known protocol values.


7. Prioritized Recommendations

Tier 1: Correctness Bugs (Fix Immediately)

These are not just performance issues — they can cause data corruption or crashes.

# Issue File Fix
1 CSV writer has no mutex io/csv_writer.go:38 Add sync.Mutex, match protobuf writer pattern
2 Elastic sendBulk() race in Close() io/elastic.go:220 Acquire mutex before sendBulk() in Close()
3 Round-robin counter race collector/collector.go:609 Use atomic.AddInt64
4 Chan writer blocking send io/chan_writer.go:182 Add buffered channel + select/timeout

Tier 2: High-Impact Performance (10-50% Throughput Gain)

# Issue File Fix
1 No PacketContext pooling collector/worker.go:77 sync.Pool
2 Protobuf writer mutex per write io/protobuf.go:117 Batch buffer, per-decoder writers
3 StreamPool coarse lock reassembly/stream_pool.go:33 Shard by flow hash
4 SSH reflection unmarshaling decoder/stream/ssh/messages.go:374 Code-gen marshaling
5 Double data copy in fragments decoder/core/data_fragments.go:49 io.MultiReader
6 Unbounded DNS cache resolvers/dns.go:32 Bounded LRU
7 10-second DNS timeout resolvers/dns.go:91 Async DNS, 1-2s timeout
8 Unbounded GeoIP cache resolvers/geoip.go:37 Bounded LRU
9 Filter reflection per record internal/filter/filter.go:204 Pre-compiled accessors

Tier 3: Medium-Impact (5-15% Improvement)

# Issue File Fix
1 PacketBufferSize default 100 collector/config.go:40 Increase to 1000+
2 1000 default workers collector/config.go:40 CPU-proportional
3 time.Now() per packet collector/worker.go:69 Cache per iteration
4 String metrics in hot path collector/worker.go:139 Cache label strings
5 Slice appends without prealloc decoder/stream/service/service_probe.go make([]T, 0, cap)
6 O(n) flow lookups decoder/stream/http/http_reader.go:265 Use map
7 Repeated .String() calls decoder/stream/tcp/tcp_connection.go:361 Cache in locals
8 CIDR parsed every call internal/filter/helpers.go:56 Parse at init
9 Regex compiled every call internal/filter/helpers.go:151 Cache compiled
10 MAC strings.ToUpper per packet resolvers/mac.go:119 Normalize at insert
11 Regex in mail loop decoder/stream/mail/mail_security.go:206 Package-level var
12 JSON writer lock scope io/json_writer.go:115 Marshal outside lock
13 Elastic infinite retry io/elastic.go:459 Exponential backoff + max retries
14 Elastic JSON per-record io/elastic.go:421 Batch JSON encoding
15 Service port range expansion resolvers/service.go:148 Store range objects

Tier 4: Schema & Long-Term

# Issue File Fix
1 String fields → enums netcap.proto Define enums for protocols
2 Field number ordering netcap.proto Move frequent fields to 1-15
3 Repeated string → packed enum netcap.proto:281 Enum array
4 Batch write API decoder/core/api.go Accumulate, flush in batches
5 Per-packet TCP data copy decoder/stream/tcp/tcp_connection.go:218 Reference counting
6 Reassembly memory shrinking reassembly/stream_pool.go:41 Periodic high-water shrink

8. Verification Strategy

Before Starting

# Establish baseline benchmarks
make -f Makefile.test test-bench
# Save profiles for comparison
cp cpu.prof cpu.prof.baseline
cp mem.prof mem.prof.baseline

After Each Tier

# Run race detector to verify correctness fixes
make -f Makefile.test test-race

# Run unit tests
make -f Makefile.test test-unit

# Run benchmarks and compare
make -f Makefile.test test-bench
go tool pprof -diff_base cpu.prof.baseline cpu.prof

End-to-End Validation

# Integration tests with real PCAPs
make -f Makefile.test test-integration

# Memory profiling under load
go build -o net ./cmd/
./net capture -read large-test.pcap -memprofile mem.prof
go tool pprof mem.prof

# CPU profiling under load
./net capture -read large-test.pcap -cpuprofile cpu.prof
go tool pprof cpu.prof

Key Metrics to Track

  • Packets/second throughput on standardized PCAP
  • Peak RSS memory usage
  • GC pause times via GODEBUG=gctrace=1
  • Lock contention via go tool pprof -contentionz
  • Allocation rate via go tool pprof -alloc_space