Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions collector/config/config_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ type Config struct {
TargetCPUIDs string `mapstructure:"pin_cpu_ids"`
Probes []component.ID `mapstructure:"probes"`

PIDNamespaceTranslationMode tracer.PIDNamespaceTranslationMode `mapstructure:"pid_namespace_translation_mode"`

// Configuration options that users can not set directly:
//
// PinnedCPUIDs is derived from TargetCPUIDs during Validate
Expand Down
28 changes: 28 additions & 0 deletions collector/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (

"github.com/stretchr/testify/require"
"go.opentelemetry.io/collector/confmap"

"go.opentelemetry.io/ebpf-profiler/tracer"
)

// validConfig returns a config with valid defaults for testing.
Expand Down Expand Up @@ -163,6 +165,32 @@ func TestUnmarshalText(t *testing.T) {
}
}

func TestPIDNamespaceTranslationModeUnmarshal(t *testing.T) {
for _, tt := range []struct {
value string
want tracer.PIDNamespaceTranslationMode
wantErr bool
}{
{value: "auto", want: tracer.PIDNamespaceTranslationModeAuto},
{value: "exact", want: tracer.PIDNamespaceTranslationModeExact},
{value: "descendants", want: tracer.PIDNamespaceTranslationModeDescendants},
{value: "invalid", wantErr: true},
} {
t.Run(tt.value, func(t *testing.T) {
cfg := validConfig()
err := confmap.NewFromStringMap(map[string]any{
"pid_namespace_translation_mode": tt.value,
}).Unmarshal(cfg)
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
require.Equal(t, tt.want, cfg.PIDNamespaceTranslationMode)
})
}
}

func TestValidateTargetCPUIDs(t *testing.T) {
for _, tt := range []struct {
name string
Expand Down
2 changes: 2 additions & 0 deletions internal/controller/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ func (c *Controller) Start(ctx context.Context) error {
OBIProcessCtx: c.config.OBIProcessCtx,
PIDNamespaceTranslation: c.config.PIDNamespaceTranslation,
ProcessMetaEnrichers: c.config.ProcessMetaEnrichers,

PIDNamespaceTranslationMode: c.config.PIDNamespaceTranslationMode,
})
if err != nil {
return fmt.Errorf("failed to load eBPF tracer: %w", err)
Expand Down
7 changes: 4 additions & 3 deletions support/ebpf/integration_test.ebpf.c

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just sharing my initial impressions:

It looks like BTF becomes a hard requirement with this change. I’m okay with that, though it might be worth explicitly pointing it out for users.

Manually configuring PIDNamespaceTranslation alongside TranslateDescendantPIDs could get tricky—wondering if an auto setting might be cleaner?

Curious to get your thoughts on how this compares to #1657?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review!

Re BTF, is only needed for descendant translation, same-namespace translation still works without it. There's a note in tracer.Config, but happy to document it somewhere more user-facing if you have a place in mind.

RE auto setting, descendant mode needs is only needed in certain scenarios and not sure we should force BTF everywhere (but you might have more context here).

In general, I agree tho that two boolean is a code smell. I've changed that to an enum and renamed it to PIDNamespaceTranslationMode and documented that the setting is ignored when translation is disabled (ideally we could delete the boolean but I guess we want to be backward compatible).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RE auto setting, descendant mode needs is only needed in certain scenarios and not sure we should force BTF everywhere (but you might have more context here).

How much overhead would it be if descendant mode is always on, given that BTF and the required BTF descriptions are available? If I understand the eBPF loop correctly, the CPU overhead is negligible (loop early exit).

I am asking because in a mixed/complex environment, the nesting of namespaces is not always obvious and/or it's complex/tedious to configure manually.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If by “always on” you mean enabling it when the BTF fields resolve and otherwise keeping exact translation, that makes sense.

I agree the CPU sampling overhead should be negligible: same-namespace tasks skip the loop, and a direct child only checks two levels. Off-CPU profiling may be different because this runs on scheduler hooks, but we can measure that separately.

We’d still need a strict mode for Kind so missing BTF fails instead of silently dropping child workloads.

@simonepri simonepri Aug 30, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added an auto mode with the logic I mentioned above and made it the default. Let me know what you think

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TY, sounds good to me!

@rockdaboot rockdaboot Aug 31, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Curious to get your thoughts on how this compares to #1657?

@florianl TY for asking this Q! #1811 helped me to get some better understanding, so sharing it here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the comparison is accurate for #1801 versus #1657.

Both address the same Kind topology: the profiler runs in the node container's PID namespace and workloads run in descendant namespaces.

#1657 selects a PID using the profiler's namespace depth. However, sibling Kind nodes can exist at the same depth, and a PID from one sibling is not valid in another.

#1801 matches the actual profiler namespace, translates both PID and TID, and rejects sibling namespaces.

So #1801 is functionally the same as #1657 just without the sibling-attribution issue.

Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
#include "tracemgmt.h"
#include "types.h"

static EBPF_INLINE void send_sample_traces(void *ctx, u64 pid)
static EBPF_INLINE void send_sample_traces(void *ctx, u64 pid, u64 tid)
{
// Use the per CPU record for trace storage: it's too big for stack.
PerCPURecord *record = get_pristine_per_cpu_record();
Expand All @@ -18,6 +18,7 @@ static EBPF_INLINE void send_sample_traces(void *ctx, u64 pid)
Trace *trace = &record->trace;

// Use COMM as a marker for our test traces. COMM[3] serves as test case ID.
bpf_get_current_comm(trace->comm, sizeof(trace->comm));
trace->comm[0] = 0xAA;
trace->comm[1] = 0xBB;
trace->comm[2] = 0xCC;
Expand All @@ -26,7 +27,7 @@ static EBPF_INLINE void send_sample_traces(void *ctx, u64 pid)

trace->comm[3] = 1;
trace->pid = pid;
trace->tid = pid;
trace->tid = tid;

u64 *data = push_frame(&record->state, trace, FRAME_MARKER_NATIVE, 0, 21, 1);
if (data) {
Expand Down Expand Up @@ -60,7 +61,7 @@ int tracepoint_integration__sched_switch(void *ctx)

printt("pid %d in integration test", pid);

send_sample_traces(ctx, pid);
send_sample_traces(ctx, pid, tid);

return 0;
}
15 changes: 15 additions & 0 deletions support/ebpf/native_stack_trace.ebpf.c
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ BPF_RODATA_VAR(u32, stack_ptregs_offset, 0)
// internal view (e.g., reporting PID 1 instead of the host PID).
BPF_RODATA_VAR(bool, pid_ns_translation_enabled, false)

// If enabled, tasks in descendant PID namespaces are translated by walking
// their PID namespace hierarchy when the kernel helper cannot resolve them.
BPF_RODATA_VAR(bool, translate_descendant_pids, false)

// The inode number of the target PID namespace.
// Obtained by calling stat() on /proc/self/ns/pid.
BPF_RODATA_VAR(u64, target_pid_ns_inode, 0)
Expand All @@ -62,6 +66,17 @@ BPF_RODATA_VAR(u64, target_pid_ns_inode, 0)
// Required by the bpf_get_ns_current_pid_tgid helper to uniquely
// identify the namespace filesystem (nsfs) instance.
BPF_RODATA_VAR(u64, target_pid_ns_dev, 0)

// Kernel BTF-derived layout used to translate tasks in descendant PID
// namespaces into target_pid_ns_inode. bpf_get_ns_current_pid_tgid only
// handles tasks whose active PID namespace exactly matches the target.
BPF_RODATA_VAR(u32, task_thread_pid_offset, 0)
BPF_RODATA_VAR(u32, pid_level_offset, 0)
BPF_RODATA_VAR(u32, pid_numbers_offset, 0)
BPF_RODATA_VAR(u32, upid_size, 0)
BPF_RODATA_VAR(u32, upid_nr_offset, 0)
BPF_RODATA_VAR(u32, upid_ns_offset, 0)
BPF_RODATA_VAR(u32, pid_namespace_inum_offset, 0)
// origin_id_sampling is set during load time.
BPF_RODATA_VAR(u16, origin_id_sampling, 0)

Expand Down
104 changes: 96 additions & 8 deletions support/ebpf/tracemgmt.h
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,22 @@ extern u16 origin_id_sampling;
// pid_ns_translation_enabled is declared in native_stack_trace.ebpf.c
extern bool pid_ns_translation_enabled;

extern bool translate_descendant_pids;

// target_pid_ns_inode is declared in native_stack_trace.ebpf.c
extern u64 target_pid_ns_inode;

// target_pid_ns_dev is declared in native_stack_trace.ebpf.c
extern u64 target_pid_ns_dev;

extern u32 task_thread_pid_offset;
extern u32 pid_level_offset;
extern u32 pid_numbers_offset;
extern u32 upid_size;
extern u32 upid_nr_offset;
extern u32 upid_ns_offset;
extern u32 pid_namespace_inum_offset;

// Mirrors the kernel's struct bpf_pidns_info for use with bpf_get_ns_current_pid_tgid().
// pid: thread PID as seen within the target PID namespace.
// tgid: thread group ID (= process PID in userspace) within the target PID namespace.
Expand All @@ -82,6 +92,67 @@ struct bpf_pidns_info {
u32 tgid;
};

// Linux permits levels 0 through 32, including the root PID namespace.
#define PID_NAMESPACE_MAX_LEVELS 33

// Resolve the PID of task as visible in the configured target namespace.
static inline EBPF_INLINE bool get_pid_in_target_namespace(u64 task, u32 *result)
{
u64 pid_address = 0;
if (
bpf_probe_read_kernel(
&pid_address, sizeof(pid_address), (void *)(task + task_thread_pid_offset)) ||
pid_address == 0) {
return false;
}

u32 active_level = 0;
if (bpf_probe_read_kernel(
&active_level, sizeof(active_level), (void *)(pid_address + pid_level_offset))) {
return false;
}

for (u32 depth = 0; depth < PID_NAMESPACE_MAX_LEVELS; depth++) {
if (depth > active_level) {
break;
}

u32 level = active_level - depth;
u64 upid_address = pid_address + pid_numbers_offset + ((u64)level * upid_size);

u64 namespace_address = 0;
if (
bpf_probe_read_kernel(
&namespace_address, sizeof(namespace_address), (void *)(upid_address + upid_ns_offset)) ||
namespace_address == 0) {
continue;
}

u32 namespace_inode = 0;
if (
bpf_probe_read_kernel(
&namespace_inode,
sizeof(namespace_inode),
(void *)(namespace_address + pid_namespace_inum_offset)) ||
namespace_inode != (u32)target_pid_ns_inode) {
continue;
}

u32 translated_pid = 0;
if (
bpf_probe_read_kernel(
&translated_pid, sizeof(translated_pid), (void *)(upid_address + upid_nr_offset)) ||
translated_pid == 0) {
return false;
}

*result = translated_pid;
return true;
}

return false;
}

// get_pid_tgid resolves the current task's PID and TGID, translating them into the
// configured target PID namespace if pid_ns_translation_enabled is set. Returns false if
// the task could not be resolved (e.g. it is not part of the target namespace), in which
Expand All @@ -92,16 +163,33 @@ static inline EBPF_INLINE bool get_pid_tgid(u32 *pid, u32 *tid)
struct bpf_pidns_info ns_info = {0};
long ret = bpf_get_ns_current_pid_tgid(
target_pid_ns_dev, target_pid_ns_inode, &ns_info, sizeof(ns_info));
if (ret < 0) {
// Task is not in the target namespace, signal caller to skip it.
if (ret == 0) {
// ns_info.tgid is the thread group ID (= process PID in userspace) in the namespace.
// ns_info.pid is the thread PID in the namespace.
// Match the convention of the non-namespace path where pid holds the TGID.
*pid = ns_info.tgid;
*tid = ns_info.pid;
return true;
}

if (!translate_descendant_pids) {
return false;
}
// ns_info.tgid is the thread group ID (= process PID in userspace) in the namespace.
// ns_info.pid is the thread PID in the namespace.
// Match the convention of the non-namespace path where pid holds the TGID.
*pid = ns_info.tgid;
*tid = ns_info.pid;
return true;

u64 task = bpf_get_current_task();
u64 group_leader = 0;
if (
task == 0 ||
bpf_probe_read_kernel(
&group_leader, sizeof(group_leader), (void *)(task + task_group_leader_offset)) ||
group_leader == 0) {
return false;
}

// A helper miss can mean either a descendant namespace or an unrelated
// namespace. Both translations validate the target namespace inode, so
// untranslated host PIDs are never returned from this path.
return get_pid_in_target_namespace(group_leader, pid) && get_pid_in_target_namespace(task, tid);
}

// bpf_get_current_pid_tgid returns (tgid << 32 | pid).
Expand Down
Binary file modified support/ebpf/tracer.ebpf.amd64
Binary file not shown.
Binary file modified support/ebpf/tracer.ebpf.arm64
Binary file not shown.
Loading