-
Notifications
You must be signed in to change notification settings - Fork 85
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Refactor Probe API #1307
base: main
Are you sure you want to change the base?
Refactor Probe API #1307
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm generally not in favor of this design.
- It has added interface complexity for the theoretical possibility of future additions.
- Deeply nesting these interface types will make this code harder to read and maintain. Instead we should strive for a flatter structure and use composition to achieve the desired functionality. Ideally our interfaces will be small and focused. For example, we could have a
Probe
interface:And then have atype Probe interface { // Base probe functionality... }
RunCloser
interface:Though we need to motivate why atype RunCloser interface { Run() Close() }
Probe
would never need to have aRun
/Close
method. - Use-cases of probes we plan to implement would help motivate this change. It would also help us define our interfaces. I think it is ambitious to want and provide a general probe running platform, but at some point we will just be providing an alternate API for the Cilium project. We need to make sure our efforts are targeted at providing auto-instrumentation.
- Deeply nesting these interface types will make this code harder to read and maintain. Instead we should strive for a flatter structure and use composition to achieve the desired functionality. Ideally our interfaces will be small and focused. For example, we could have a
- This does not address the multi-process issue from Auto Instrumentation - Monitor multiple processes #197. If we plan to release this design to end-users we need to ensure this will be supported. Otherwise, we will ship an immediately outdated design as we try to progress.
- The load and targeting mechanisms are set by assignment to returned values.
- File names of the probe package a based on Go declarations not the functionality and types they contain. This will make it harder to find code and is not idiomatic with the rest of the project.
- The
utils
package is separate from the probes, specificallyInitializeEBPFCollection
. All probes will need to maintain similar BPF filesystem utilities, and much of this will be duplicative or conflicting if we cannot somehow incorporate it into the probe factories or types.
ID() ID | ||
|
||
// GetLogger returns an *slog.Logger for this Probe. | ||
GetLogger() *slog.Logger |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Isn't this the opposite of what we want in that we want the probes to use the instrumentation logger? When would we need to have the logger from the probe returned?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah good point, this is unnecessary
} | ||
|
||
// If Probe is used, pass target details to Probe | ||
p.TargetConfig().TargetDetails = target |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is setting a value on a returned type from TargetConfig
. This is not idiomatic of Go, parameters should be passed to function calls not set on returned values.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fair enough, my point here was just to pass TargetDetails
somehow in the config for the probe rather than as a one-time argument, since the target is available at startup and we re-read it a couple times. But, this does codify the 1:1 probe:target ratio so maybe not a good idea
|
||
// Load loads the eBPF programs and maps required by the Probe into memory. | ||
// The specific types of programs and maps are implemented by the Probe. | ||
Load() error |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How do you specify what to load?
How will this handle multiple load targets when we support #197
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wanted to see if I could get rid of all the arguments to Load()
and Attach()
to make them as generic as possible. To do that, I moved loading options into the Probe's object. But, I see now that doing so actually binds us to 1 process per 1 probe, which isn't what we want.
// BaseProbe is the most basic type of Probe. It must support configuration, | ||
// loading and attaching eBPF programs, running, and closing. | ||
// All Probes must implement this Base functionality. | ||
type BaseProbe interface { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When do we expect this interface to be satisfied but not the others?
This seems like an over generalization for a project designed to produce auto-instrumentation. Shouldn't every probe produce telemetry? When they don't why would they use this project?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Every Probe should enable telemetry, but not every Probe will actually produce events. We've already talked about having Probes that don't report telemetry starting in #1105 (comment) (ie, ReportingProbe
and JobProbe
/ SyncProbe
and AsyncProbe
) when we were looking at setting the boolean for the auto sdk.
We are also now looking at a set of 3 Probes for @grcevski's new approach to context propagation: one to track socket connections, one to allocate header space, and one to insert the new header. In this case, only the 3rd probe actually handles a telemetry event (as I understand it).
So, I don't think we should assume that each Probe object is necessarily going to be emitting telemetry, or we design a way to couple enabling eBPF programs to a telemetry-producing Probe. There's pros and cons to that as well
BaseProbe | ||
|
||
// Run starts the Probe. | ||
Run() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How does this produce telemetry? There is no destination for that data being communicated to the probe.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I moved the handler function to a field on the Probe object. Unlike the target details, I think this does make sense unless we want to change handler functions for a Probe during runtime.
default: | ||
return nil, fmt.Errorf("unknown probe type") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems like this only supports GoLibraryTelemetryProbe
. Why not just have that probe defined and add new ones when we support those?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could totally do that. But, in this proposal I tried to point out other areas where for example a Probe only needs to be a RunnableProbe
or where we'd specifically want to pass tracing config to a TracingProbe
(ie, not a Metrics or Logs probe)
I get that this switch statement looks dumb, and I should have been more explicit in what I was trying to show with it
BaseProbe | ||
|
||
// Run starts the Probe. | ||
Run() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we want to pass context
here?
Do we want to commit to the current approach that Run
is blocking until canceled?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think a context makes sense here
|
||
// Attach attaches the eBPF programs to trigger points in the process. | ||
// The specific attachment points are implemented by the Probe. | ||
Attach() error |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we need to support a use case of Loading the eBPF program once and attaching it to multiple locations in runtime.
For that reason, I think the Attach
function should probably have an argument specifying where to attach to.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point, do you think Load
needs any run time arguments too?
|
||
// Manifest returns the Probe's instrumentation Manifest. This includes all | ||
// the information about any packages the Probe instruments. | ||
Manifest() Manifest |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was thinking about adding version support to the Manfiest
(#1105 (comment))
Do you think that it will fit here or should the Manfiest
be a part of the more basic interfaces?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right now Manifest
is pretty Go-specific. I think version support could be added to the Manifest interface separate of the Probe refactor, but if we want Manifest
to be part of a more basic interface then we will commit to the idea that each Probe is tied to an application library instrumentation (see #1307 (comment))
} | ||
|
||
// TargetExecutableProbe is a provided implementation of GoLibraryTelemetryProbe. | ||
type TargetExecutableProbe[BPFObj any] struct { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if we need the generic type here - as it seems we don't make use of it?
I agree with most of @MrAlias points, except the multi-process one which I addressed in #1219 (comment) |
@MrAlias @RonFed this is great feedback, thanks guys. My goal was to just get something concrete down to see how it actually looked, and it's helpful to hear what works and what doesn't from that.
@MrAlias that's actually exactly what I was going for, but I missed the mark by embedding the interfaces. I think a composition design gives us the most flexibility in the framework. But, like you said in #1307 (comment) we could go the whole other direction and only support one very specific Probe right now, adding others as we find them.
I had the exact same thought about this just being a wrapper on the Cilium API, lol. Obviously we don't want that so keeping the goals in mind is important. I did actually have specific use cases that we have discussed in mind while I was doing this, so maybe that helps explain some of the weirder code:
That’s a good point, and I think I actually made it worse by moving For multi-process support, I agree with @RonFed that it should be handled at a higher level in the framework (Manager or Instrumentor) rather than a single Probe object knowing that it’s tied to multiple processes. Maybe that’s what you’re saying too, or I’m misunderstanding. But either way, what I did here by coupling target executable to a Probe is definitely not what we want.
This is helpful feedback for the implementation, but as for the fundamental design approach I’m proposing does this relate to the changes? Just want to make sure I understand where all the feedback falls. |
Notes from the SIG meeting today:
Let me know if I missed anything. I'll try to incorporate some of the feedback from this thread and the meeting into this PR where I can, if anything just to see how it looks. By all means anyone else feel free to open an alternative or more comments for changes you'd like to see to this PR. Thanks! |
SIG meeting notes:
|
Ref #1105
To support custom (modular) Probes, we want a Probe API that is:
All in all, this came out less heavy than I expected. But there is still a lot of nuance here, especially around making sure the interface design truly is flexible enough for us to expand in the future. As always, I'm open to any feedback on this.
Summary
In this design, I am proposing modifying our Probe API to rely on a layered composition of interfaces to define Probes. Today, this gives the appearance of simple inheritance. However, as more diverse functionality support is added in the future, I believe this will show more truly as a composition of base features.
To enable this composition, I am proposing adding extension points into our existing framework, gated by interface checks. Again, this is not very compelling with our single use case today but sets us up for adding new extension points in the future.
Current Design Problems
Our current, single
Probe
interface is designed with the assumption that all Probes will be Loaded, Run, and produce a Go Library Manifest.Note that this does not include any indication that our current Probes will produce any telemetry. It also means that any new Probes that aren’t based on a Go program, or a specific target, or that perhaps don’t need to be triggered to Run continuously must implement superfluous functions.
Instead, we have achieved polymorphism through structs:
Base
,SpanProducer
, andTraceProducer
. While this has worked well for our different needs specific to Go library trace-producing Probes, these structs are still bound to a single interface definition which limits our ability to add new functionality and Probe types as time goes on.Proposal
Interfaces
To start, I broke down the functionality of our current Probes into layers. Each Probe we have today:
From these points I am proposing the initial interfaces:
BaseProbe
- Must provide ID, Logging, Loading/Attaching, and Config functions. This are the base requirements for all Probes.RunnableProbe
- Implements Run and Close functions.TracingProbe
- Accepts Tracing config, such as Sampling.GoLibraryTelemetryProbe
- Provides a Go Manifest and Target Executable config. This is what our current Probes are, but note that this only embeds aRunnableProbe
and not aTracingProbe
to allow us to implement future telemetry interfaces (such asMetricsProbe
).My goal with this is to provide different “branching-off” points for new types of Probes in the future. For example, we may have a
RunnableProbe
that relies on kprobes or network events.The list of interfaces we define will be baked into the framework and outline the functionality that is available in the framework. End-user developers will not be able to define their own interfaces, as that wouldn’t make sense (because they can’t customize the framework itself). However, users will be able to create their own implementations.
Default implementations
For each of these new Probe interfaces, I’m providing a default implementation:
BasicProbe
- Simple (partial) implementation ofBaseProbe
, but doesn’t have Load or Attach functionality.TargetExecutableProbe
- Implementation ofGoLibraryTelemetryProbe
that provides Load, Attach, Target Config, and Manifest functions.TargetEventProducingProbe
- Generic struct that provides aread()
helper for our 2 types of telemetry Probes.TargetSpanProducingProbe
andTargetTraceProducingProbe
- BothRunnableProbe
s that are the equivalent of our current Probe offerings.Refactors
The main refactor in this was the addition of several interface checks in the Manager: for example, we only call
Run()
on aRunnableProbe
, and we only check for the GoManifest()
on aGoLibraryTelemetryProbe
.As part of accepting the new interfaces, some helpers were consolidated (for example, now
FilterUnusedProbes
isFilterUnusedProbesForTarget
, with target info passed to the relevant Probes in order to simplify functions likeLoad()
).On that note,
Load()
has been simplified for the purpose of abstractingBasicProbe
. The target executable information and Sampling config are now provided by the manager, if appropriate for the type of Probe.New features
As suggested in #1105 (comment),
Load()
has been split intoLoad()
andAttach()
. This function also no longer relies on the Sampling config, allowing it to be used for non-tracing Probes in the future. (Instead, Sampling config should be provided by callingprobe.TracingConfig().SamplingConfig
on aTracingProbe
).All Probes are also now expected to implement a custom
ApplyConfig()
function, which accepts a genericinterface{}
as an argument. The Probe can then attempt to convert theinterface{}
to its own unique config format for Probe-specific settings (see an example in thenet/http
probe).