docs/refactoring-guide.md
This guide documents the strategy, patterns, and key considerations for refactoring probe modules in the eCapture project, based on the successful refactoring of the gotls probe using bash probe as a reference template.
The refactoring effort aims to standardize probe implementations to follow a consistent architecture using:
// domain.Probe - Main probe interface
type Probe interface {
Initialize(ctx context.Context, config Configuration, dispatcher EventDispatcher) error
Start(ctx context.Context) error
Stop(ctx context.Context) error
Close() error
Name() string
IsRunning() bool
Events() []*ebpf.Map
}
// domain.EventDecoder - For decoding eBPF events
type EventDecoder interface {
Decode(em *ebpf.Map, data []byte) (Event, error)
GetDecoder(em *ebpf.Map) (Event, bool)
}
// domain.Event - Event structure interface
type Event interface {
DecodeFromBytes(data []byte) error
String() string
StringHex() string
Clone() Event
Type() EventType
UUID() string
Validate() error
}
type Probe struct {
*base.BaseProbe // Embed BaseProbe for common functionality
config *Config // Probe-specific configuration
bpfManager *manager.Manager // eBPF program manager
eventMaps []*ebpf.Map // eBPF event maps
// Probe-specific fields (e.g., file handles, state)
}
type Config struct {
*config.BaseConfig // Embed BaseConfig for common settings
// Probe-specific configuration fields
SpecificField1 string `json:"specific_field1"`
SpecificField2 bool `json:"specific_field2"`
}
func (c *Config) Validate() error {
// Validate BaseConfig first
if err := c.BaseConfig.Validate(); err != nil {
return err
}
// Validate probe-specific fields
return nil
}
Reference: internal/probe/bash/config.go
Extend config.BaseConfig:
type Config struct {
*config.BaseConfig
// ... probe-specific fields
}
Update NewConfig():
func NewConfig() *Config {
return &Config{
BaseConfig: config.NewBaseConfig(),
// ... initialize probe-specific defaults
}
}
Update Validate() method:
func (c *Config) Validate() error {
if err := c.BaseConfig.Validate(); err != nil {
return errors.NewConfigurationError("validation failed", err)
}
// ... probe-specific validation
return nil
}
Remove redundant methods that are now inherited from BaseConfig:
GetPid(), GetUid(), GetDebug(), GetHex(), GetBTF(), etc.Keep probe-specific methods and ensure Bytes() delegates to BaseConfig if no special serialization needed.
Reference: internal/probe/bash/bash_probe.go
Initialize BaseProbe:
func NewProbe() (*Probe, error) {
return &Probe{
BaseProbe: base.NewBaseProbe("probename"),
}, nil
}
Implement Initialize():
func (p *Probe) Initialize(ctx context.Context, cfg domain.Configuration,
dispatcher domain.EventDispatcher) error {
// Call BaseProbe.Initialize first
if err := p.BaseProbe.Initialize(ctx, cfg, dispatcher); err != nil {
return err
}
// Type assert to probe-specific config
probeConfig, ok := cfg.(*Config)
if !ok {
return errors.NewConfigurationError("invalid config type", nil)
}
p.config = probeConfig
// Log probe initialization with relevant fields
p.Logger().Info().
Str("field1", probeConfig.Field1).
Msg("Probe initialized")
// Probe-specific initialization (open files, etc.)
return nil
}
Implement Start():
func (p *Probe) Start(ctx context.Context) error {
// Call BaseProbe.Start
if err := p.BaseProbe.Start(ctx); err != nil {
return err
}
// Load eBPF bytecode
bpfFileName := p.BaseProbe.GetBPFName("bytecode/probe_kern.o")
byteBuf, err := assets.Asset(bpfFileName)
if err != nil {
return errors.NewEBPFLoadError(bpfFileName, err)
}
// Setup and initialize eBPF manager
if err := p.setupManager(); err != nil {
return err
}
if err := p.bpfManager.InitWithOptions(bytes.NewReader(byteBuf),
p.getManagerOptions()); err != nil {
return errors.NewEBPFLoadError("manager init", err)
}
// Start eBPF manager
if err := p.bpfManager.Start(); err != nil {
return errors.NewEBPFAttachError("manager start", err)
}
// Get event maps
eventsMap, found, err := p.bpfManager.GetMap("events")
if err != nil || !found {
return errors.NewResourceNotFoundError("eBPF map: events")
}
p.eventMaps = []*ebpf.Map{eventsMap}
// Start event readers
if err := p.StartPerfEventReader(eventsMap, p); err != nil {
return err
}
return nil
}
Implement setupManager():
func (p *Probe) setupManager() error {
p.bpfManager = &manager.Manager{
Probes: []*manager.Probe{
{
Section: "uprobe/function_name",
EbpfFuncName: "uprobe_function",
AttachToFuncName: "target_function",
BinaryPath: p.config.BinaryPath,
},
// ... more probes
},
Maps: []*manager.Map{
{Name: "events"},
// ... more maps
},
}
return nil
}
Implement getManagerOptions():
func (p *Probe) getManagerOptions() manager.Options {
opts := manager.Options{
DefaultKProbeMaxActive: 512,
VerifierOptions: ebpf.CollectionOptions{
Programs: ebpf.ProgramOptions{
LogSizeStart: 2097152,
},
},
RLimit: &unix.Rlimit{
Cur: math.MaxUint64,
Max: math.MaxUint64,
},
}
// Add constant editors if kernel supports global variables
if p.config.EnableGlobalVar() {
opts.ConstantEditors = []manager.ConstantEditor{
{Name: "target_pid", Value: p.config.GetPid()},
{Name: "target_uid", Value: p.config.GetUid()},
}
}
return opts
}
Update Close():
func (p *Probe) Close() error {
// Stop eBPF manager
if p.bpfManager != nil {
if err := p.bpfManager.Stop(manager.CleanAll); err != nil {
p.Logger().Warn().Err(err).Msg("Failed to stop eBPF manager")
}
}
// Clean up probe-specific resources
// Call BaseProbe.Close
return p.BaseProbe.Close()
}
Implement Events():
func (p *Probe) Events() []*ebpf.Map {
return p.eventMaps
}
Reference: internal/probe/bash/event.go
Import required packages:
import (
"github.com/gojue/ecapture/internal/domain"
"github.com/gojue/ecapture/internal/errors"
)
Rename Decode() to DecodeFromBytes():
func (e *Event) DecodeFromBytes(data []byte) error {
buf := bytes.NewBuffer(data)
if err := binary.Read(buf, binary.LittleEndian, &e.Field1); err != nil {
return errors.NewEventDecodeError("Event.Field1", err)
}
// ... decode other fields
return nil
}
Implement domain.Event interface methods:
// String returns a human-readable representation
func (e *Event) String() string {
return fmt.Sprintf("Field1:%v, Field2:%v", e.Field1, e.Field2)
}
// StringHex returns a hexadecimal representation
func (e *Event) StringHex() string {
hexData := fmt.Sprintf("%x", e.Data)
return fmt.Sprintf("Field1:%v, Data(hex):%s", e.Field1, hexData)
}
// Clone creates a new instance
func (e *Event) Clone() domain.Event {
return &Event{}
}
// Type returns the event type
func (e *Event) Type() domain.EventType {
return domain.EventTypeOutput
}
// UUID returns a unique identifier
func (e *Event) UUID() string {
return fmt.Sprintf("%d_%d", e.Pid, e.Timestamp)
}
// Validate checks if the event data is valid
func (e *Event) Validate() error {
// Validation logic
return nil
}
Remove Encode() method if present (not needed for domain.Event interface)
Add these methods to the Probe:
// Decode implements EventDecoder interface
func (p *Probe) Decode(em *ebpf.Map, data []byte) (domain.Event, error) {
for _, m := range p.eventMaps {
if m == em {
event := &Event{}
if err := event.DecodeFromBytes(data); err != nil {
return nil, err
}
return event, nil
}
}
return nil, fmt.Errorf("unknown eBPF map: %s", em.String())
}
// GetDecoder implements EventDecoder interface
func (p *Probe) GetDecoder(em *ebpf.Map) (domain.Event, bool) {
for _, m := range p.eventMaps {
if m == em {
return &Event{}, true
}
}
return nil, false
}
For probes with multiple event types (like gotls with TLSDataEvent and MasterSecretEvent):
func (p *Probe) Decode(em *ebpf.Map, data []byte) (domain.Event, error) {
for i, m := range p.eventMaps {
if m == em {
if i == 0 { // First map is data events
event := &DataEvent{}
if err := event.DecodeFromBytes(data); err != nil {
return nil, err
}
return event, nil
}
if i == 1 { // Second map is metadata events
event := &MetadataEvent{}
if err := event.DecodeFromBytes(data); err != nil {
return nil, err
}
return event, nil
}
}
}
return nil, fmt.Errorf("unknown eBPF map: %s", em.String())
}
Reference: internal/probe/bash/bash_probe_test.go or updated internal/probe/gotls/gotls_probe_test.go
Create a mock dispatcher:
type mockDispatcher struct{}
func (m *mockDispatcher) Register(handler domain.EventHandler) error { return nil }
func (m *mockDispatcher) Unregister(handlerName string) error { return nil }
func (m *mockDispatcher) Dispatch(event domain.Event) error { return nil }
func (m *mockDispatcher) Close() error { return nil }
Update test initialization:
func TestProbe_Initialize(t *testing.T) {
probe, err := NewProbe()
if err != nil {
t.Fatalf("NewProbe() failed: %v", err)
}
cfg := NewConfig()
// Set config fields
ctx := context.Background()
dispatcher := &mockDispatcher{}
if err := probe.Initialize(ctx, cfg, dispatcher); err != nil {
t.Errorf("Initialize() failed: %v", err)
}
// Assertions
if probe.config == nil {
t.Error("expected config to be set")
}
probe.Close()
}
Test with race detector:
go test -race -v ./internal/probe/yourprobe/...
Perf buffers are per-CPU. When eBPF programs emit samples with bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, ...), userspace reads and merges multiple per-CPU buffers, so the raw read order is not guaranteed to match global probe trigger order. This is especially visible for multiplexed protocols such as HTTP/2.
BaseProbe.StartPerfEventReader supports optional userspace lag-window reorder before dispatch:
BaseConfig.PerfReorder (json:"perf_reorder") and BaseConfig.PerfReorderLagMs (json:"perf_reorder_lag_ms").domain.MonoNsEvent and returning the original bpf_ktime_get_ns() value from PerfMonoNs().perf_reorder is requested but the event decoder does not expose a MonoNsEvent, BaseProbe automatically falls back to the normal perf reader and logs that reorder was ignored.gotls, tls/openssl, mysqld, and postgres.Use the original eBPF monotonic timestamp as the reorder key. For gotls, use GoTLSDataEvent.BpfMonoNs because Timestamp may be rewritten for display fallback. For openssl, mysql, and postgres, the existing Timestamp field is still the raw bpf_ktime_get_ns() value and is safe to return from PerfMonoNs().
Problem: Event decoding fails due to field alignment or size mismatches between Go struct and eBPF C struct.
Solution:
pahole to check C struct layoutProblem: Missing initialization, improper cleanup, or no logging context.
Solution:
p.BaseProbe.Initialize() first in Initializep.BaseProbe.Start() first in Startp.BaseProbe.Close() last in Closep.Logger() instead of creating new loggersProblem: eBPF map names in code don't match names in kernel code.
Solution:
kern/*.c filessetupManager(): {Name: "events"}bpftool map list if debugging liveProblem: Probe has multiple eBPF maps but only decodes one event type.
Solution:
p.eventMapsDecode() methodProblem: Tests fail after refactoring because they use old API.
Solution:
-race flagProblem: Using generic errors instead of domain-specific error types.
Solution:
errors.NewConfigurationError() for config issueserrors.NewEBPFLoadError() for eBPF loading issueserrors.NewEBPFAttachError() for attachment issueserrors.NewEventDecodeError() for event decoding issuesNewConfig() and default valuesConfig.Validate() with valid and invalid inputsNewProbe() creationInitialize() with different configurationsClose() cleanup# Run unit tests
go test -v ./internal/probe/yourprobe/...
# Run with race detector
go test -race -v ./internal/probe/yourprobe/...
# Run with coverage
go test -cover -v ./internal/probe/yourprobe/...
# Run specific test
go test -v ./internal/probe/yourprobe/... -run TestProbe_Initialize
Use this checklist when refactoring a probe:
func (e *MyEvent) DecodeFromBytes(data []byte) error {
if len(data) < 24 {
return errors.NewEventDecodeError("MyEvent",
fmt.Errorf("data too short: %d bytes", len(data)))
}
buf := bytes.NewBuffer(data)
if err := binary.Read(buf, binary.LittleEndian, &e.Timestamp); err != nil {
return errors.NewEventDecodeError("MyEvent.Timestamp", err)
}
if err := binary.Read(buf, binary.LittleEndian, &e.Pid); err != nil {
return errors.NewEventDecodeError("MyEvent.Pid", err)
}
// ... more fields
return nil
}
func (p *Probe) setupManager() error {
binaryPath := p.config.BinaryPath
if binaryPath == "" {
return errors.NewConfigurationError("binary_path required", nil)
}
p.Logger().Info().
Str("binary_path", binaryPath).
Msg("Setting up eBPF probes")
p.bpfManager = &manager.Manager{
Probes: []*manager.Probe{
{
Section: "uprobe/my_function",
EbpfFuncName: "uprobe_my_function",
AttachToFuncName: "target_function",
BinaryPath: binaryPath,
},
},
Maps: []*manager.Map{
{Name: "events"},
},
}
return nil
}
Following this guide ensures:
When in doubt, reference the bash probe implementation as the canonical example of this pattern.