This is the multi-page printable view of this section. Click here to print.
Development
- 1: Extending Observability
- 2: Development Debugging
- 3: Integration Test
- 4: Debug a VM in GitHub Actions
- 5: BPF ABI Guide
- 6: Challenges of TCP Retransmission and dropwatch Correlation
- 7: Time Format Contract
- 8: Event Field Naming
1 - Extending Observability
HUATUO supports three extension types: Metrics, Event, and AutoTracing. They use the same registration framework but differ in activation, runtime cost, and data output.
| Type | Activation | Data output | Use case |
|---|---|---|---|
| Metrics | Periodic collection | Prometheus | Continuous performance monitoring and long-term trends |
| Event | Kernel event or threshold | ES, local files, optional Prometheus | Continuous operation with anomaly context capture |
| AutoTracing | System anomaly | ES, local files, optional Prometheus | On-demand, higher-cost context capture |
Collection Modes
Metrics
Metrics periodically collect system state through procfs, sysfs, or eBPF and expose it in Prometheus format. This mode supports real-time monitoring and long-term trend analysis. Built-in collectors cover:
- CPU: sys, usr, util, load, nr_running, and related metrics.
- Memory: vmstat, memory_stat, directreclaim, and asyncreclaim.
- I/O: d2c, q2c, freeze, and flush.
- Networking: ARP, socket memory, qdisc, netstat, netdev, and sockstat.
Event
Events continuously observe kernel events or threshold conditions and preserve kernel context when an anomaly occurs. This mode is intended for low-overhead, always-on observation. Data is written to Elasticsearch and local files and can also produce Prometheus metrics. Built-in events include:
- Scheduler tick interval anomalies (
sched_tick). - Abnormal memory allocation (
memory_oom_kill). - Soft lockups (
softlockup). - D-state processes (
hungtask). - Memory reclaim (
memory_reclaim_events). - Packet drops (
dropwatch). - Network receive latency (
net_rx_latency).
AutoTracing
AutoTracing invokes diagnostic tools after detecting a system anomaly. It is intended for flame graphs, context snapshots, and other diagnostic operations that are too expensive to run continuously. Results are written to Elasticsearch and local files and can also be converted into Prometheus metrics. Built-in capabilities include:
- CPU idle and system-time anomaly tracing (
cpuidle,cpusys). - D-state load tracing (
dload). - Burst memory allocation tracing (
memburst). - Disk I/O anomaly tracing (
iotracing).
Event and AutoTracing are both Tracing modes and share the ITracingEvent
interface. They can preserve anomaly context for root-cause analysis and expose
statistics to Prometheus by also implementing Collector.
Adding Metrics
Custom Metrics collectors expose Prometheus metrics through /metrics.
Implement Collector
Create a type under core/metrics that implements Collector:
type Collector interface {
Update() ([]*Data, error)
}
type exampleMetric struct{}
func (c *exampleMetric) Update() ([]*metric.Data, error) {
return []*metric.Data{
metric.NewGaugeData("example", value, "example value", nil),
}, nil
}
Register the collector
Use FlagMetric when registering the implementation:
func init() {
tracing.RegisterEventTracing("example", newExampleMetric)
}
func newExampleMetric() (*tracing.EventTracingAttr, error) {
return &tracing.EventTracingAttr{
TracingData: &exampleMetric{},
Flag: tracing.FlagMetric,
}, nil
}
Manage BPF object
When one implementation provides both Start and Update, the methods may
run concurrently. Do not read and write a bpf.BPF interface directly in a
collector field. Use Reference and Lease from
internal/bpf/bpf_ref.go to manage the
object lifetime:
type example struct {
object bpf.Reference
}
func (c *example) Start(ctx context.Context) (retErr error) {
object, err := bpf.LoadBpf(bpf.ThisBpfOBJ(), nil)
if err != nil {
return err
}
if err := object.Attach(); err != nil {
return errors.Join(err, object.Close())
}
if err := c.object.Publish(object); err != nil {
return errors.Join(err, object.Close())
}
defer func() {
retErr = errors.Join(retErr, c.object.UnPublish())
}()
<-ctx.Done()
return nil
}
func (c *example) Update() ([]*metric.Data, error) {
lease, ok := c.object.Acquire()
if !ok {
return nil, nil
}
defer lease.Release()
items, err := lease.DumpMapByName("example_map")
if err != nil {
return nil, fmt.Errorf("dump example_map: %w", err)
}
return buildMetrics(items), nil
}
The API has these constraints:
Publishtransfers ownership of the object toReference. Do not callobject.Close()directly after a successful publish.- The
Leasereturned byAcquirepins the BPF object until the currentUpdatecompletes. Always pair it withRelease, and do not copy a Lease. UnPublishfirst prevents new acquisitions, then waits for every Lease to be released, closes the BPF object, and returns the close error.- Calls to
PublishandUnPublishmust be serialized. The current framework does not runStartconcurrently for the same instance.
An Update that has already started can therefore finish with its original
BPF object. During shutdown or restart, Start closes that object only after
those updates complete.
Adding an Event
An Event implements ITracingEvent:
type ITracingEvent interface {
Start(ctx context.Context) error
}
type exampleEvent struct{}
func (e *exampleEvent) Start(ctx context.Context) error {
// Detect the event and capture its context.
// storage.Save writes the data to ES and local storage.
storage.Save("example", containerID, time.Now(), eventData)
return nil
}
Register it with FlagTracing:
func init() {
tracing.RegisterEventTracing("example", newExampleEvent)
}
func newExampleEvent() (*tracing.EventTracingAttr, error) {
return &tracing.EventTracingAttr{
TracingData: &exampleEvent{},
Interval: 10,
Flag: tracing.FlagTracing,
}, nil
}
To expose Prometheus metrics for the event, also implement Collector and add
tracing.FlagMetric to Flag.
Adding AutoTracing
AutoTracing and Event use the same ITracingEvent interface and registration
framework:
type exampleAutoTracing struct{}
func (t *exampleAutoTracing) Start(ctx context.Context) error {
// Capture context after the anomaly trigger fires.
storage.Save("example", containerID, time.Now(), tracingData)
return nil
}
func init() {
tracing.RegisterEventTracing("example", newExampleAutoTracing)
}
func newExampleAutoTracing() (*tracing.EventTracingAttr, error) {
return &tracing.EventTracingAttr{
TracingData: &exampleAutoTracing{},
Interval: 10,
Flag: tracing.FlagTracing,
}, nil
}
See core/metrics, core/events, and core/autotracing for complete examples
covering BPF map interaction, container metadata, storage, and Prometheus
output.
2 - Development Debugging
Full-Stack Integration
The development Compose configuration builds an image from the current workspace and starts the collector, API Server, Elasticsearch, Prometheus, and Grafana. The collector needs access to the host kernel and cgroups, so run the command with root privileges from the repository root on a Linux host:
sudo make compose-dev-up
Compose aggregates all component logs in the foreground. After changing the
source, press Ctrl+C and run the command again. Docker reuses the toolchain
layers and Go build cache.
Remove the containers, data volumes, and development image after debugging:
sudo make compose-dev-down
This command removes the Elasticsearch data volume. Do not run it when the integration data must be retained.
BPF Debugging
BPF code can use the bpf_dbg() and bpf_dbg_msg() macros to emit debug
information from kernel space. The macros are defined in
bpf/include/bpf_dbg.h. Debugging has separate build-time and runtime switches
and is completely disabled by default.
Add debug trace points
Each BPF source file that uses the macros must declare its own debug map:
#include "bpf_dbg.h"
BPF_DBG_MAP(native_cpu);
SEC("perf_event")
int prog(void *ctx)
{
bpf_dbg_msg(ctx, native_cpu, "enter prog");
bpf_dbg(ctx, native_cpu, "pid and addr", pid, addr, 0);
return 0;
}
bpf_dbg_msg() emits a message only. bpf_dbg() also accepts up to three
u64 arguments.
Build debug objects
Set BPF_DEBUG=1 to pass -DDEBUG_BPF to Clang:
make BPF_DEBUG=1
To regenerate build artifacts without rebuilding binaries:
make BPF_DEBUG=1 gen-build
BPF_DEBUG=0 is the default. In that mode the macros expand to no-ops, and the
debug perf event array, event structure, bpf_ktime_get_ns, and
bpf_perf_event_output are not emitted into the BPF object.
Enable runtime output
After building the debug objects, pass --log-bpf-debug when starting the
profiler. The option currently applies only to the native profiler:
./profiler --type cpu --language native --log-bpf-debug ...
When loading the BPF object, bpf.NewDbg(true) rewrites the
bpf_dbg_enabled constant to 1 before LoadBpf. When it is disabled, the
verifier eliminates the branch as dead code. Each BPF object maintains an
independent switch.
Read debug output
User space emits each debug event at Debug level with these fields:
file: BPF source file.line: source line number.ts: event timestamp converted to UTC wall-clock time.msg: debug message.args: up to threeu64arguments, omitted when all values are zero.
bpf_dbg: file=native_oncpu_profiler.c line=120 ts=2026-01-11T08:30:00.123456Z msg=enter prog args=[0x1f4 0xffff8881 0x0]
Debug output requires both a build with BPF_DEBUG=1 and the runtime
--log-bpf-debug option.
3 - Integration Test
This integration test validates that huatuo-bamai can start correctly with mocked /proc and /sys filesystems and expose the expected Prometheus metrics.
The test runs the real huatuo-bamai binary and verifies the /metricsendpoint output without relying on the host kernel or hardware.
What the Script Does
The integration test performs the following steps:
- Generates a temporary
bamai.conf - Starts
huatuo-bamaiwith mockedprocfsandsysfs - Waits for the Prometheus
/metricsendpoint to become available - Fetches all metrics from
/metrics - Verifies that all expected metrics exist
- Stops the service and cleans up resources
If any expected metric is missing, the test fails.
How to Run
Run the integration test from the project root:
bash integration/run.sh
Pass a file name to run one integration test. The optional second argument is the repeat count and defaults to 1:
bash integration/run.sh test_metrics_exclude_filter.sh 10
or
make integration
Prerequisites and Results
Both suites use integration/run.sh, which defaults to integration.
e2e/run.sh forwards to --suite e2e. Existing make integration, make e2e,
and single-case entry points remain available:
bash integration/run.sh --suite e2e
bash integration/run.sh --suite e2e test_metrics.sh 2
bash e2e/run.sh test_metrics.sh 2
Both suites run in separate UTS and mount namespaces with hostname huatuo-dev
and private mount propagation. Integration cases start their own services;
e2e starts the baseline bamai before each case, then stops it and checks its log.
Integration removes workspaces after passed or skipped cases and preserves them
on failure. E2E retains its workspaces.
The runner checks only that _output exists, without checking individual
project binaries or BPF objects. Run
make build before invoking a runner directly. An existing directory does not
prove that the build is complete; missing artifacts fail when used.
Use the same helper for commands on PATH and executable file paths. Missing commands skip the case:
require_commands jq curl ss
require_commands "${PROFILER_TOOL_DIR}/bin/asprof"
Use require_readable for input files such as certificates. Unreadable inputs
skip the case and report the affected path:
require_readable "${KUBELET_CERT}" "${KUBELET_KEY}"
skip prints its reason and exits with 77; EXIT cleanup still runs. The runner
classifies 0 as PASS, 77 as SKIP, and other statuses as FAIL. Cleanup failures also
count as FAIL. A suite containing only passed and skipped cases returns 0.
A failure stops subsequent cases and prints the results collected so far.
Each execution counts separately, including repetitions. For example:
summary: total=5 passed=3 skipped=2 failed=0
When root privileges or namespace commands are unavailable, both suites count all selected executions as SKIP.
On Failure
- The
huatuo-bamaiservice metrics and logs are printed to stdout - The temporary working directory is kept for debugging
On Success
- Output the list of successfully validated metrics
Background Process Cleanup
stop_and_wait_by_pid PID [TIMEOUT] stops and waits for a child process,
returning its exit status. An omitted or empty PID returns success immediately,
so callers can pass an initialized PID variable without checking it first.
Clear the variable after waiting; use || true in best-effort cleanup and
assert the exit status when it is part of the test contract.
Network Namespace Helpers
Network tests can source integration/lib_namespace.sh after lib.sh:
namespace_setup NAMEcreates one network namespace and exposes its name asNETNS. The caller brings uploand configures any addresses it needs.namespace_setup_with_pair PREFIX SERVER_ADDR CLIENT_ADDR [PREFIX_LENGTH]creates two namespaces connected by a veth pair, configures their addresses, and brings up the interfaces. The prefix length defaults to 24. Names and addresses are exposed throughNETNS_SERVER,NETNS_CLIENT,NETNS_VETH_SERVER,NETNS_VETH_CLIENT,NETNS_SERVER_ADDR, andNETNS_CLIENT_ADDR.namespace_cleanupdeletes the namespaces created by either helper and clears their state. Call it from the test’sEXITcleanup after stopping processes in those namespaces. Repeated cleanup calls are supported.
How to Add New Metrics Tests
1: Add or Update Fixture Data
If the metric depends on /proc or /sys, add or update mock data under:
integration/fixtures/
The directory structure should match the real kernel filesystem layout.
2: Add Expected Metrics
Create a new file under:
integration/fixtures/expected_metrics/
├── cpu.txt
├── memory.txt
└── ...
Each non-empty, non-comment line represents one expected Prometheus metric line and must match the /metrics output exactly.
New *.txt files are automatically picked up by the test.
3: Run the Test
bash integration/run.sh
The test fails if any expected metric is missing or mismatched.
4 - Debug a VM in GitHub Actions
Use the OS Distro QEMU Test workflow to test a branch in a supported Linux
VM. No local VM setup is required.
Prepare Your Fork
- Fork the repository and enable workflows on the fork’s Actions page.
- Add an SSH public key to your GitHub account. Only the user who starts the workflow can connect.
- Push the branch that you want to debug to your fork.
Start a VM
- Open Actions > OS Distro QEMU Test > Run workflow in your fork.
- Select your branch.
- Keep the default
ubuntu24.04, enter comma-separated distributions, or useallto select every supported distribution. - Select when to retain the VM and the retention time under Debug VM.
- Run the workflow and open its
Test in VMjob.
If Run workflow is missing, sync the fork’s default branch first. GitHub only lists manual workflows that exist on the default branch.
Use Before test to inspect or change the VM before tests run. Use After test on failure to investigate a failed test, or After test always to keep the VM regardless of the result.
Connect
Open the job’s Debug VM: Retain ... step and follow its commands:
- Run the displayed SSH command in your local terminal. This connects to the GitHub runner.
- On the runner, run the displayed
ssh -i ... root@<VM_IP>command to enter the VM.
The commands already contain the correct key path and VM address.
Finish
Exit the VM, then release the workflow from the runner:
cd "$GITHUB_WORKSPACE"
touch continue
The workflow continues to the tests or cleanup. Disconnecting SSH alone does not release it; otherwise it waits until the selected retention time expires.
5 - BPF ABI Guide
The C structures on the BPF side are the source of truth for the perf event ABI. Go types are generated automatically from BTF in the BPF objects. Do not maintain equivalent structures manually.
Conventions
Each ABI domain maps to one C header and one generated Go file:
| Item | Convention |
|---|---|
| Domain name | <domain> |
| C header | bpf/include/abi/<domain>_types.h |
| C structure prefix | <domain>_ |
| Generated Go file | internal/bpf/abi/<domain>_types_generated.go |
<domain> must start with a lowercase letter and contain only lowercase
letters, digits, and underscores. Avoid domains with overlapping prefixes,
such as net and net_rx.
Implementation
The following example creates the sample domain.
1. Define the ABI Header
Create bpf/include/abi/sample_types.h:
// Copyright 2026 The HuaTuo Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
#ifndef __BPF_ABI_SAMPLE_H__
#define __BPF_ABI_SAMPLE_H__
#include "bpf_abi.h"
#define SAMPLE_DATA_LEN 4
struct sample_detail {
u32 code;
u8 data[SAMPLE_DATA_LEN];
};
struct sample_event {
u64 kernel_observed_ns;
struct sample_detail detail;
u32 tgid;
u8 kind;
u8 pad[3];
};
BPF_ABI_EXPORT(sample_detail);
BPF_ABI_EXPORT(sample_event);
#endif /* __BPF_ABI_SAMPLE_H__ */
Requirements:
- Structure names must use the
<domain>_prefix. - Every structure that requires a generated Go type must call
BPF_ABI_EXPORT(<type>), including nested structures. - The header must contain only ABI types and related constants. Do not add BPF programs, maps, or business logic.
- Array lengths must be compile-time constants and remain consistent across all BPF compilation units.
2. Use the Header in a BPF Program
The ABI header depends on base types such as u8 and u16. Include it after
vmlinux.h and the project base headers:
#include "vmlinux.h"
#include "bpf_common.h"
#include "abi/sample_types.h"
Use the ABI structure directly when emitting a perf event:
struct sample_event event = {};
event.kernel_observed_ns = bpf_ktime_get_ns();
event.tgid = bpf_get_current_pid_tgid() >> 32;
event.kind = kind;
bpf_perf_event_output(ctx, &events, COMPAT_BPF_F_CURRENT_CPU,
&event, sizeof(event));
The event must be zero-initialized to prevent uninitialized padding from being
written to the perf buffer. Any structure passed to bpf_perf_event_output
must be defined in an ABI header.
3. Generate and Use the Go Type
Run the following command from the repository root:
make gen-build
The generated file is located at:
internal/bpf/abi/sample_types_generated.go
Reference the generated type directly from Go code:
import "github.com/ccfos/huatuo/internal/bpf/abi"
var event abi.SampleEvent
if err := reader.ReadInto(&event); err != nil {
return fmt.Errorf("read sample event: %w", err)
}
The generated file contains the structure, the SampleEventSize size
constant, and layout assertions based on unsafe.Sizeof and
unsafe.Offsetof. Generated files are read-only. Do not edit them manually.
Types and Layout
| Type | Support |
|---|---|
| Fixed-width integers of 1, 2, 4, or 8 bytes | Supported |
| Fixed-length arrays with a nonzero length | Supported |
| Nested structures that meet the same constraints | Supported |
typedef with a supported target type |
Supported |
Pointers, union, enum, and floating-point types |
Unsupported |
Bit fields and _Bool |
Unsupported |
| Zero-length and flexible arrays | Unsupported |
| Recursive structures, overlapping fields, or non-byte-aligned structures | Unsupported |
Layout requirements:
- Use fixed-width integers such as
u8,s16,u32, ands64. - Order fields according to their alignment requirements. Use
u8 pad[N]to make padding explicit when necessary. - Do not use platform-dependent types such as
longorunsigned long. - Do not use
__attribute__((packed))to bypass layout validation. - If a structure with the same name appears in multiple BPF objects, its fields, offsets, and size must match exactly.
C names are converted to exported Go names by splitting on underscores. For
example, sample_event becomes SampleEvent, and kernel_observed_ns becomes
KernelObservedNS. Field semantics and cross-layer names follow
Event Field Naming. Avoid C names that map to the
same Go name, such as sample_id and sample_i_d.
Verification
Run the following commands after adding or modifying an ABI:
make gen-build
go test ./build/bpfabi-tool
make check
Add a decoding test for each new perf event. At a minimum, cover:
- Integer boundary values and native byte order.
- Nested structures and the first and last array elements.
- Fields before and after padding.
- The sample size and the generated
<GoType>Sizeconstant.
Common Errors
| Error | Check |
|---|---|
has no "<domain>_" btf anchors |
Confirm that a BPF source includes the header and that the type calls BPF_ABI_EXPORT |
without matching abi header |
Confirm that the header name matches the structure prefix and that domain prefixes do not overlap |
differs between objects |
Check whether conditional compilation, array length macros, or included headers change the layout |
go offset is ... btf offset is ... |
Reorder fields or add explicit padding; do not use packed |
go type name ... collides |
Rename C types or fields that map to the same Go name |
6 - Challenges of TCP Retransmission and dropwatch Correlation
This document describes the evidence boundaries and the implementation of local correlation. The two event streams share no common event ID, so correlation can only make conservative inferences from the namespace, the 4-tuple, TCP sequence/ACK evidence, and monotonic time.
1. Result Semantics
| Result | Meaning |
|---|---|
software |
A host software drop was found that satisfies all strict conditions and has not been consumed yet. |
hardware |
A devlink DROP trap record was found that satisfies the same strict conditions. |
unknown |
No strict match was found, or the matched record’s source enumeration is unknown; correlation_reason explicitly distinguishes matched from unmatched terminal outcomes. |
A no-match does not prove that the problem is in the network or in hardware. The drop may have happened before capture started, in another network namespace, or the record may have been delivered without the TCP matching fields.
Shutdown processes the waiting entries with a single time snapshot: expired
entries receive warmup or wait_timeout, and entries that have not expired
receive interrupted. All of these unmatched results report
drop_location=unknown plus independent diagnostics.
2. Three Operating Scenarios
tcpshark --bpf-path <tcp_retransmit.o>only captures and directly outputs retransmissions.tcpshark --with-dropwatch --bpf-path-dir <dir>loadstcp_retransmit.oandnet_dropwatch.oinside one process, which owns both perf inputs, the timer, the output, and shutdown.- The huatuo-bamai standalone dropwatch still runs independently through
cmd/dropwatch, outputs only rawDropWatchTracing, and shares no state with the embedded source.
All three retransmit hooks use the synthetic L3 filter in both modes.
Correlation mode uses only EventTracing.TCPRetransmit.Filter; an empty value
is normalized to tcp, and the same expression is passed to both BPF objects.
Expressions that depend on Ethernet addresses and cannot run equivalently on
synthetic L3 input are rejected before startup.
EventTracing.Dropwatch.Filter controls only standalone dropwatch.
Both commands use HardwareAuto to auto-detect devlink trap support. When the
tracepoint is unavailable, they log a warning and continue software capture;
when it is available, they accept only driver-reported DROP traps.
--device / --device-excluded share their names with standalone dropwatch
and are mutually exclusive; they filter only the embedded source’s software and
hardware records and require --with-dropwatch. The retransmit input still
uses the shared L3 filter.
Drop source and reason are resolved uniformly by
internal/dropwatch.ResolveMetadata. Software reasons are looked up in a BTF
table loaded once per session; unknown values are decimal numbers, and kernels
without support yield NOT_SUPPORTED. Hardware reasons and groups come from
the trap name and the trap group respectively and do not consult the software
reason table. The reader keeps independent source and reason strings and does
not borrow the reusable ABI buffer. After a strict match, the output carries
drop_source, drop_reason, and drop_reason_group; a no-match omits these
fields and outputs the single correlation terminal reason plus independent
diagnostics. drop_location echoes drop_source on a match and is unknown
otherwise; it expresses the correlation classification and differs from the
standalone dropwatch kernel address. An unknown source is never inferred from a
reason or a stack. Strict matching cannot be established when a hardware record
lacks the namespace or TCP matching fields.
3. The Two Time Constraints
dropwatch and retransmit use different perf readers, and events can also come from different CPUs, so the userspace arrival order does not equal the kernel occurrence order:
CPU 2: drop monotonic_ns=180, held back in the dropwatch ring
CPU 0: retransmit monotonic_ns=200, reaches userspace first
userspace: retransmit(200) -> drop(180)
The implementation uses two windows on different dimensions:
retransmitRetentionDuration = 100 * time.Millisecond
maxDropToRetransmitAge = time.Second
- 100 ms is the userspace out-of-order delivery budget. When a retransmission
arrives first, it enters the wait queue; if it is still unmatched when the
wait expires, it is reported as
unknown. Records whose deadline has already passed are finalized before any new event is processed; the timer is only responsible for waking the loop and does not define the deadline semantics. - 1s is the causal candidate age, judged with the BPF
kernel_observed_ns. A candidate must satisfy:
drop.kernel_observed_ns <= retransmit.kernel_observed_ns
retransmit.kernel_observed_ns - drop.kernel_observed_ns <= 1s
observed_timestamp is userspace wall time, affected by scheduling and system
time adjustments, and does not participate in matching. The fixed 1s is the
currently supported contract and is not a theoretical bound for all Linux RTOs;
results outside the window stay unknown.
4. Bounded State
Waiting retransmissions and not-yet-matched drops share the in-package generic
store store[T], each holding an independent instance. The container is
responsible only for capacity, TTL, the flow index, and deletion; strict
matching and result finalization belong to the correlator.
type storeEntry[T any] struct {
value T
flow *flowKey
deadline time.Time
sequence uint64
node *list.Element
}
type store[T any] struct {
byDeadline list.List
byFlow map[flowKey][]*storeEntry[T]
capacity int
ttl time.Duration
nextSequence uint64
}
| Instance | Stored value | Capacity | TTL |
|---|---|---|---|
retransmitStore |
waitingRetransmit, inlined in the entry |
1024 | 100ms |
dropStore |
*dropEvent |
4096 | 1s + 100ms |
waitingRetransmit contains only the raw event, the matching fields, and the
cross-namespace candidate flag; it no longer holds a list node, a deadline, or
an independent ID. Drops likewise need no extra cache wrapper type. The list’s
Element.Value and the flow bucket reference the same *storeEntry[T], and
the entry’s node points back to its list element. The entry references the
flow from the business data, which is never modified, avoiding a duplicate
address. Deletion unreferences both indexes at once and clears the tail of the
flow slice; an empty bucket is removed after its last entry is deleted.
The flow index is keyed by the canonical order of the two AddrPort values, so
both directions share one bucket; the original direction is kept in the
business data for strict matching. The namespace is not part of the index key,
so similar cross-namespace candidates can still set the diagnostic flag. Match
scans and slice deletions are O(k), where k is the number of candidates for
that flow; locating the earliest deadline and unlinking from the list are O(1).
Each instance has a fixed TTL, and the correlation loop passes a monotonically
non-decreasing processing time, so list insertion order is also deadline order.
The list serves expiry cleanup and capacity eviction and exposes no FIFO
consumption interface to business code. When the wait capacity is full, the
record with the earliest deadline is finalized as queue_full; when the drop
capacity is full, the candidate with the earliest deadline is evicted directly.
Causal decisions still use kernel_observed_ns.
The two readers deliver events through unbuffered channels, and the container
is accessed only by a single correlation loop, with no locks and no separate
cleanup goroutine. One timer always takes the earliest deadline of the two
instances, so cleanup happens on schedule even when only the drop cache holds
data and no retransmission is waiting. On every input and timer wakeup, expired
entries are cleaned first, then matching and capacity checks run;
now >= deadline means expired. The 100 ms is the matching deadline; blocking
in synchronous output can still delay actual cleanup and result delivery.
5. Strict Matching
A forward segment candidate requires:
- the same network namespace, address family, and direction 4-tuple;
- a drop no later than the retransmission, with a time difference of at most 1s;
- an overlap between the TCP SYN/data/FIN sequence range and the retransmitted range;
- RST and unsupported retransmission types produce no forward match.
A reverse ACK candidate uses the reverse 4-tuple and requires the ACK to cover the retransmitted sequence end. SYN and SYN-ACK use their own stricter ACK/SYN conditions.
Among multiple drop candidates, the largest drop.kernel_observed_ns is
selected first; on a time tie, the larger insertion sequence number wins. When
a drop arrives late, it selects the strictly matched retransmission with the
smallest insertion sequence number while scanning the remaining candidates to
record each retransmission’s namespace-match status. After a strict match, the
entry is removed from both the deadline and flow indexes immediately and can be
consumed only once. Forward segment and reverse ACK share the flow index; the
namespace is checked first, then the packet and time conditions.
When a drop with the same namespace appears on the same flow, netNamespace
is set to true; the flag is retained even when time or sequence fails the
strict conditions, and later non-matching candidates do not clear it. A strict
match always yields true. With no candidates, only other namespaces, or an
incomparable namespace, the value is false, meaning no match was observed; it
does not establish that the namespaces differ. The output preserves this
status through NetNamespace; the JSON and text field is
matched_net_namespace, omitted when false.
6. Mutually Exclusive Correlation Outcomes
types.CorrelationReason is a string type; each correlation outputs exactly
one correlation_reason:
| Go constant | JSON value | Meaning |
|---|---|---|
CorrelationMatched |
matched |
Strict match succeeded, including drops whose source is unknown. |
CorrelationUnsupported |
unsupported |
The current rules cannot handle this retransmission; it does not enter the wait queue. |
CorrelationWarmup |
warmup |
The wait expired without a match, and the retransmission predates source readiness. |
CorrelationWaitTimeout |
wait_timeout |
The wait expired without a match, and the retransmission occurred at or after source readiness. |
CorrelationQueueFull |
queue_full |
The retransmission was evicted before expiry because the wait queue was full. |
CorrelationInterrupted |
interrupted |
The correlation loop exited, and the unexpired wait was interrupted early. |
Each terminal branch assigns correlationResult.reason directly. The output
side uses this enum to classify results and validates that matched must carry
a drop while the other five outcomes must not; empty values, unknown values, or
contradictory combinations return an error. Correlation-disabled output omits
the field; once correlation is enabled, every finalized event contains one
valid terminal outcome. The scalar field replaces the previous reason array;
tools and receivers migrate in lockstep.
On wait expiry, the terminal outcome is chosen by the retransmission’s kernel
time: earlier than source readiness yields warmup, at or after readiness
yields wait_timeout, including waits under 1s after readiness. The 1s
drop-to-retransmit limit applies only to candidate matching and plays no part
in the warmup decision. Userspace processing delay does not change the
classification. This decision runs only in the expiry branch; strict matches,
unsupported events, queue evictions, and early interruptions keep their own
reasons. Warmup neither blocks matching nor ends the wait early.
matched_net_namespace remains an independent diagnostic flag: a drop with the
same namespace was observed on the same flow, independent of the packet and
time conditions; it cannot substitute for correlation_reason=matched.
The flag can coexist with rate limiting and loss counters without changing the single terminal outcome. Drops that fail normalization and drop-cache capacity evictions do not directly end a retransmission wait and do not produce a retransmission-level reason.
7. Perf Status
The status consists of three independent channels; each reports on its own and none is summed with another:
perf_lost: the cumulative per-CPUbpf_perf_out_dropwatchmap (the common perf output accounting facilitybpf_perf_output.h); BPF increments it whenbpf_perf_event_outputreturns a negative value (for example, when the current CPU has no attached reader).lost_samples: the userspace cumulative count of ring buffer overflows reported byPERF_RECORD_LOST, visible only while the reader runs, and queryable through ReadStatus once the lost records are processed.rate_limited: thetotal_missedvalue of the rate-limit status mapbpf_rlimit_dropwatch.
On success, ReadStatus sets HasMapCounters=true, serialized as
map_counters_available=true. On error the flag is false and PerfLost and
RateLimited are zero, meaning unavailable rather than an absence of loss or
rate limiting; LostSamples remains valid and the output keeps that snapshot.
These values are per-instance cumulative counters, sampled separately by the
map and the reader, and cannot explain why any individual retransmission failed
to match.
Userspace sums perf_lost across all CPUs and returns the current snapshot each time; it does not check for counter regressions or uint64 addition overflow. This status describes evidence integrity only and never promotes a no-match into a deterministic network classification. The old active-epoch, dual-slot, inflight, frontier, and drain watermark mechanisms have been removed.
The correlation side computes the TCP sequence span from the IPv4 total length or the IPv6 payload length. Under GSO/offload the IP header length may not cover the whole skb; matching is not widened on that basis today.
The dropwatch hot path is:
software: hardware marker lookup/delete -> device/pcap filter
-> status map lookup -> bpf_ktime_get_ns() -> rate limit -> perf output
hardware: device/pcap filter -> status map lookup -> bpf_ktime_get_ns()
-> rate limit -> perf output
Consequently, events rejected by the filter pay no status lookup, event-time helper, or counter update cost; a software drop first consumes any existing hardware dedup marker so rejected kfree paths leave no marker behind.
8. Shutdown
tcpshark uses one errgroup.WithContext to manage the retransmit rate-limit
reader, the retransmit reader, the embedded dropwatch reader, and the
correlation loop. All command workers share the same groupCtx; when any
worker returns an error the rest are canceled, and Wait returns the first
worker error. Resource close errors are merged by each owner with
errors.Join; shutdown no longer maintains a private error-aggregating group.
internal/dropwatch.Tracer owns the dropwatch object, the readers, and the
rate-limit alert worker. The instance context derives from groupCtx; a
rate-limit alert failure cancels instance reads, and the concrete error is
returned by Close. ReadInto checks close and cancellation causes before
reading; once the underlying read is entered, the read result is returned
as-is and is no longer overwritten by a subsequent close or cancellation.
ReadStatus can still read the final counters after the context is canceled.
Close first cancels the instance, detaches, and closes the event and alert
readers, then waits for the alert worker to exit, and finally releases the
object; closing the alert reader wakes an idle read, so no polling deadline
needs to elapse. The status type is types.DropwatchStatus, the output field
is drop_perf_status, and it includes the map-counter availability flag.
On normal completion or worker failure:
- Cancel the shared
groupCtx; - the two
ReadIntoreaders and the correlation loop exit; - records still in the dropwatch perf ring that were never handed to the correlator are no longer read;
drainRetransmits(now)is called to finalize expired entries with the same time snapshot, then clear the unexpired ones, writing output in deadline order;- after all workers exit, the dropwatch and retransmit Tracers are closed, collecting internal alert-worker and resource-release errors;
- finally the socket output is finished; the caller-provided io.Writer is not closed by the session.
At shutdown, expired entries report warmup or wait_timeout and unexpired
entries report interrupted, all retaining drop_location=unknown, the
diagnostic flags, and the perf status. Already matched or already evicted
events are never finalized twice. Tail drops can still be lost, so a
retransmission that could have matched may also be ended early.
9. File Responsibilities
cmd/tcpshark/
├── main.go # signals, version, CLI entry point
├── cli.go # argument validation, path and filter normalization
├── run.go # global BPF lifecycle, timeout, feature dispatch
└── retransmit/ # retransmit feature package
├── config.go # Config, RunConfig
├── run.go # Run: resource assembly, execution, and cleanup
├── tracer.go # Open, ReadInto, Close, and the rate-limit worker
├── bpf_load.go # loadBPF, attachBPF, and probe selection
├── event_reader.go # dual-stream reading, conversion, and channel delivery
├── correlation_loop.go # select, timer, and exit finalization
├── correlation.go # correlation state machine, expiry finalization
├── correlation_match.go # candidate selection, consumption, and namespace-match status
├── match.go # matching types: namespace/flow/sequence/ktime
├── store.go # entry storage, flow index, capacity and expiry management
├── event.go # conversion of the two ABI record types
├── classify.go # retransmission classification
├── correlation_output.go # status completion, symbolization, correlation output fields
└── output.go # text, JSON, and socket output
The top level runs the retransmit feature through
retransmit.Run(ctx, *RunConfig) and does not touch ABI records, channels, the
correlation timer, or status counters. The CLI validates scalars and argument
combinations first, then compile-checks the filter. When a non-empty
--output-storage is set, the --output value is not validated; if the latter
is given explicitly, a note on stderr says it is ignored. Without storage, only
json or text is accepted. RunConfig.Tracing is the capture configuration;
when Dropwatch is nil, it reads and outputs directly; when non-nil, it uses two
read workers and a single correlation loop. The correlation capability is a
composition of the retransmit feature, not a general capture framework.
Run builds the output before attaching probes and, before returning, completes waiting for all session workers, final finalization, Tracer closing, and output completion. Configuration failures, cancellation, and run failures all release the resources already acquired. Draining kernel buffers at close is not guaranteed. Plain mode creates no correlation channels or timers.
retransmit.Tracer still owns only the retransmit BPF object, the readers, and
the rate-limit alert worker; event conversion, classification, correlation, and
output belong to the feature session in the same package and stay out of the
Tracer. An Open failure rolls back every acquired resource; Close is still
required after the session context is canceled. A rate-limit worker failure
cancels the capture, and the original error is also preserved by Close. Once
reading has started, ReadInto keeps the underlying result as-is and no longer
checks close or cancellation state. Close may interrupt a read, but callers
must serialize Close calls. Process-level bpf.Init/Shutdown and the run
timeout are managed centrally at the command layer.
The correlator uniformly cleans up expired candidates while handling each input and timer wakeup; the caches do not repeat expiry checks. The correlator returns match evidence, a single terminal outcome, and diagnostic flags, and does not modify output fields; correlation_output.go builds the final output in one place. Each non-empty output batch reads the dropwatch status once at its entry, and empty batches read nothing; symbols are resolved only for matched drops. Status is read even when the whole batch is matches; on a read failure the batch is still written out and the status error is returned afterwards; if the write fails, batch output stops and the write error is merged with the status error. Only no-match events carry the status snapshot and diagnostic flags; a failed read still keeps valid reader counters, and matched events do not carry these fields. Internal retransmission events keep only the ABI record and the observedAt from a successful read; the wait queue does not store full presentation objects. Matching constructs netip addresses directly from ABI addresses and uses the ABI event enums, with no string conversions. IPv4-mapped IPv6 is still normalized to IPv4. Single-event reads handle lost-record retries and read times in one place; plain mode reuses the event and outputs synchronously, while correlation mode reads into a dedicated event before sending it, avoiding overwriting data that is still waiting. run.go creates the channels and starts the read workers in one place, and the sender closes the channels; read functions start no goroutines and use neither consumer callbacks nor borrowed-event contracts. TCPRetransmitTracing is constructed only at final output; ObservedTimestamp uses the original read time, not the correlation end time. The ABI, SYNACK flag completion, and matching rules stay unchanged.
cmd/tcpshark/retransmit does not enforce a Go internal import restriction;
the convention is that only tcpshark uses it, and it does not depend back on
the command layer. The capture implementation shared by the two commands lives
in:
internal/dropwatch/
├── config.go # Config, HardwareMode, and configuration validation
├── tracer.go # Tracer API, newTracer resource takeover, rollback, and worker lifecycle
├── load.go # loadBPF, attachBPF: loading, map/reader preparation, and probe attachment
├── netdev.go # device filtering and map configuration
├── status.go # ReadStatus: output failures, ring loss, and rate-limit counters
├── reason.go # LoadReasonNames: kernel BTF reason table and numeric fallback
├── metadata.go # ResolveMetadata: source, software reason, hardware trap/group
└── decode.go # DecodePacket: ABI header adaptation and packet.Parse
Open(ctx, *Config) returns a concrete *Tracer. Each instance allows only
one ReadInto caller; status reads and closing may run concurrently, but
Close on the same instance must be serialized by the resource owner. The
first close returns cleanup errors; later calls return nil, never retry
cleanup, and never return the first error again. The caller owns global BPF
initialization and shutdown, and must close the Tracer after the final
finalization. DecodePacket results do not borrow the original record memory.
Symbolization and the DropWatchTracing presentation conversion stay in the
standalone command; TCP correlation keeps KernelObservedNS, the namespace,
stack PCs, and the resolved drop source and reason directly from the ABI. No
general BPF consumption-loop interface is introduced.
7 - Time Format Contract
Time Format Contract
HUATUO serializes timestamps in UTC with fixed nanosecond precision:
2006-01-02T15:04:05.000000000Z
For example, an event captured at half past midnight on 22 July 2026 is written as:
2026-07-22T00:30:00.123456789Z
Why fixed UTC precision
- Every host emits the same timezone (
Z), so distributed queries do not need timezone-specific handling. - The nine fractional digits keep the string width fixed. Lexical ordering therefore matches chronological ordering, which is useful for log files and keyword-indexed storage.
- Nanoseconds preserve the precision supplied by Go’s
time.Timeand by event-tracing pipelines.
Integration guidance
When producing data for HUATUO, format times with the canonical helper:
timestamp := timeutil.FormatUTC(time.Now())
When consuming timestamps, use timeutil.Parse. It accepts the canonical format and RFC 3339 timestamps with zero to nine fractional digits so that legacy records remain readable. The returned value is always normalized to UTC.
Do not compare timestamps as strings unless both values have been generated by FormatUTC; third-party RFC 3339 values can have variable fractional precision or non-UTC offsets.
8 - Event Field Naming
Event fields must use consistent semantics before following the naming conventions of each layer. The same name must not represent different concepts.
Layer Rules
| Layer | Rule | Example |
|---|---|---|
| BPF C | Names reflect the original kernel semantics and units | tgid, kernel_observed_ns |
| Go | MixedCaps; initialisms remain uppercase | TGID, KernelObservedNS |
| JSON | Use user-facing statistical semantics | pid, observed_timestamp |
| CLI flags | Lowercase kebab case | --pid, --cpuid |
| CLI text and headers | Use standard uppercase initialisms | PID, CPU, COMM |
CPUs and Tasks
| Concept | Definition | BPF C | Go | JSON | CLI |
|---|---|---|---|---|---|
| Logical CPU number | Number returned by bpf_get_smp_processor_id() |
cpu |
CPU |
cpu |
--cpu, CPU |
| Process ID | Userspace process identifier, namely the Linux TGID | tgid |
TGID in raw events; PID in output |
pid |
--pid, PID |
| Thread ID | Linux task PID | tid |
TID |
tid |
--tid, TID |
| Parent process ID | Parent process TGID | parent_tgid |
ParentTGID, or ParentPID in the output layer |
parent_pid |
PPID |
| Task comm | Kernel task comm, which may be a thread name |
comm |
Comm |
comm |
COMM |
Time and Numeric Units
| Concept | Definition | BPF C | Go | JSON |
|---|---|---|---|---|
| Raw kernel observation time | CLOCK_MONOTONIC nanoseconds, excluding system suspend |
kernel_observed_ns |
KernelObservedNS |
Internal correlation only; not serialized |
| Kernel observation time | Raw monotonic timestamp converted to UTC | Not applicable | KernelObservedTimestamp |
kernel_observed_timestamp |
| Userspace observation time | UTC time when the event producer observes the event in userspace | Not applicable | ObservedTimestamp |
observed_timestamp |
| Unix nanosecond time | Unix time in nanoseconds | timestamp_ns |
TimestampNS |
timestamp_ns |
| Nanosecond duration | Difference between two time points | <name>_ns |
<Name>NS |
<name>_ns |
| Nanosecond threshold | Duration corresponding to a trigger condition | <name>_threshold_ns |
<Name>ThresholdNS |
<name>_threshold_ns |
| Count | Unitless cumulative value | <name>_count |
<Name>Count |
<name>_count |
| Bytes | Data size, not bit count | <name>_bytes |
<Name>Bytes |
<name>_bytes |
Containers, Cgroups, and Network Namespaces
| Concept | BPF C | Go | JSON | CLI |
|---|---|---|---|---|
| Container ID | container_id |
ContainerID |
container_id |
--container-id, CONTAINER_ID |
| Cgroup ID | cgroup_id |
CgroupID |
cgroup_id |
CGROUP_ID |
| Cgroup CSS address | <subsystem>_css_addr |
<Subsystem>CSSAddr |
<subsystem>_css_addr |
<SUBSYSTEM>_CSS_ADDR |
| Network namespace inode | netns_inum |
NetNamespaceInum |
net_namespace_inum |
NETNS_INUM |
| Network namespace cookie | netns_cookie |
NetNamespaceCookie |
net_namespace_cookie |
NETNS_COOKIE |
| Network device index | ifindex or <name>_ifindex |
Ifindex or <Name>Ifindex |
ifindex or <name>_ifindex |
IFINDEX |
Kernel terms inum and ifindex remain single words in Go. Do not split them
into INum or IfIndex. User-facing Go and JSON fields expand netns to
NetNamespace and net_namespace.
Observation times and compatibility
kernel_observed_timestamp, observed_timestamp, and uploaded_timestamp
represent kernel observation, userspace observation, and storage write time.
Kernel observation means the hook execution time, which may differ from the
onset of a physical hardware error or network fault.
Document stores UTC timestamps at the top level and excludes
kernel_observed_ns. Producers without kernel timestamps and older
documents omit kernel_observed_timestamp; userspace or upload timestamps must
not fill it. Historical RAS documents used observed_timestamp for kernel
observation, so their userspace observation time cannot be reconstructed.
tcpshark and dropwatch use kernel_observed_timestamp for kernel observation
time in JSON and text output. Upgrade tools and Agent together. Existing
documents are not rewritten or backfilled. Conversion requires the same host,
boot, and host time namespace. Historical events spanning a realtime clock
step or system suspend cannot be mapped exactly to UTC from monotonic time alone.
UTC conversion caches the monotonic-to-realtime offset. The first call one hour after the last successful sample refreshes it; ordinary calls do not extend its lifetime. Sampling failures are returned and retried on the next call. Realtime clock steps and system suspend do not trigger an early refresh; the offset is updated on the first call after the cache expires.