Creating a Component¶
This page explains how to create components in detail.
This page uses the example of creating a compression component. This component compresses a payload before sending it to the Datadog backend.
Since there are multiple ways to compress data, this component provides two implementations of the same interface:
A component contains multiple folders and Go packages. Developers split a component into packages to isolate the interface from the implementations and improve code sharing. Declaring the interface in a separate package from the implementation allows you to import the interface without importing all of the implementations.
File hierarchy¶
All components are located in the comp
folder at the top of the Agent repo.
The file hierarchy is as follows:
comp /
<bundle name> / <-- Optional
<comp name> /
def / <-- The folder containing the component interface and ALL its public types.
impl / <-- The only or primary implementation of the component.
impl-<alternate> / <-- An alternate implementation.
impl-none / <-- Optional. A noop implementation.
fx / <-- All fx related logic for the primary implementation, if any.
fx-<alternate> / <-- All fx related logic for a specific implementation.
mock / <-- The mock implementation of the component to ease testing.
To note:
- If your component has only one implementation, it should live in the
impl
folder. - If your component has several implementations instead of a single implementation, you have multiple
impl-<version>
folders instead of animpl
folder. For example, your compression component hasimpl-zstd
andimpl-zip
folders, but not animpl
folder. - If your component needs to offer a dummy/empty version, it should live in the
impl-none
folder.
Why all those files ?¶
This file hierarchy aims to solve a few problems:
- Component users should only interact with the
def
folders and never care about which implementation was loaded in the main function. - Go module support: when you create a Go module, all sub folders are pulled into the module. Thus, you need different folders for each implementation, the definition, and fx. This way, an external repository can pull a specific implementation and definition without having to import everything else.
- You have one
fx
folder per implementation, to allow binaries to import/link against a single folder. - A main function that imports a component should be able to select a specific implementation without compiling with the others. For example: the ZSTD library should not be included at compile time when the ZIP version is used.
Bootstrapping components¶
You can use the invoke task deva components.new-component comp/<COMPONENT_NAME>
to generate a scaffold for your new component.
Every public variable, function, struct, and interface of your component must be documented. Refer to the Documentation section below for details.
The def folder¶
The def
folder contains your interface and ALL public types needed by the users of your component.
In the example of a compression component, the def folder looks like this:
// Package compression contains all public type and interfaces for the compression component
package compression
// team: <your team>
// Component describes the interface implemented by all compression implementations.
type Component interface {
// Compress compresses the input data.
Compress([]byte) ([]byte, error)
// Decompress decompresses the input data.
Decompress([]byte) ([]byte, error)
}
All component interfaces must be called Component
, so all imports have the form <COMPONENT_NAME>.Component
.
You can see that the interface only exposes the bare minimum. You should aim at having the smallest possible interface for your component.
When defining a component interface, avoid using structs or interfaces from third-party dependencies.
Interface using a third-party dependency
package telemetry
import "github.com/prometheus/client_golang/prometheus"
// team: agent-shared-components
// Component is the component type.
type Component interface {
// RegisterCollector Registers a Collector with the prometheus registry
RegisterCollector(c prometheus.Collector)
}
In the example above, every user of the telemetry
component would have to import github.com/prometheus/client_golang/prometheus
no matter which implementation they use.
In general, be mindful of using external types in the public interface of your component. For example, it would make sense to use Docker types in a docker
component, but not in a container
component.
The impl folders¶
The impl
folder is where the component implementation is written. The details of component implementation are up to the developer. The only requirements are that the package name follows the pattern <COMPONENT_NAME>impl
for the regular implementation or <IMPL_NAME>impl
for the alternative implementation, and that there is a public instantiation function called NewComponent
.
package zstdimpl
// NewComponent returns a new ZSTD implementation for the compression component
func NewComponent(reqs Requires) Provides {
....
}
To require input arguments to the NewComponent
instantiation function, use a special struct named Requires
. The instantiation function returns a special stuct named Provides
. This internal nomenclature is used to handle the different component dependencies using Fx groups.
In this example, the compression component must access the configuration component and the log component. To express this, define a Requires
struct with two fields. The name of the fields is irrelevant, but the type must be the concrete type of interface that you require.
package zstdimpl
import (
"fmt"
config "github.com/DataDog/datadog-agent/comp/core/config/def"
log "github.com/DataDog/datadog-agent/comp/core/log/def"
)
// Here, list all components and other types known by Fx that you need.
// To be used in `fx` folders, type and field need to be public.
//
// In this example, you need config and log components.
type Requires struct {
Conf config.Component
Log log.Component
}
Using other components
If you want to use another component within your own, add it to the Requires
struct, and Fx
will give it to you at initialization. Be careful of circular dependencies.
For the output of the component, populate the Provides
struct with the return values.
package zstdimpl
import (
// Always import the component def folder, so that you can return a 'compression.Component' type.
compression "github.com/DataDog/datadog-agent/comp/compression/def"
)
// Here, list all the types your component is going to return. You can return as many types as you want; all of them are available through Fx in other components.
// To be used in `fx` folders, type and field need to be public.
//
// In this example, only the compression component is returned.
type Provides struct {
Comp compression.Component
}
All together, the component code looks like the following:
package zstdimpl
import (
"fmt"
compression "github.com/DataDog/datadog-agent/comp/compression/def"
config "github.com/DataDog/datadog-agent/comp/core/config/def"
log "github.com/DataDog/datadog-agent/comp/core/log/def"
)
type Requires struct {
Conf config.Component
Log log.Component
}
type Provides struct {
Comp compression.Component
}
// The actual type implementing the 'Component' interface. This type MUST be private, you need the guarantee that
// components can only be used through their respective interfaces.
type compressor struct {
// Keep a ref on the config and log components, so that you can use them in the 'compressor' methods
conf config.Component
log log.Component
// any other field you might need
}
// NewComponent returns a new ZSTD implementation for the compression component
func NewComponent(reqs Requires) Provides {
// Here, do whatever is needed to build a ZSTD compression comp.
// And create your component
comp := &compressor{
conf: reqs.Conf,
log: reqs.Log,
}
return Provides{
comp: comp,
}
}
//
// You then need to implement all methods from your 'compression.Component' interface
//
// Compress compresses the input data using ZSTD
func (c *compressor) Compress(data []byte) ([]byte, error) {
c.log.Debug("compressing a buffer with ZSTD")
// [...]
return compressData, nil
}
// Decompress decompresses the input data using ZSTD.
func (c *compressor) Decompress(data []byte) ([]byte, error) {
c.log.Debug("decompressing a buffer with ZSTD")
// [...]
return compressData, nil
}
The constructor can return either a Provides
, if it is infallible, or (Provides, error)
, if it could fail. In the latter case, a non-nil error results in the Agent crashing at startup with a message containing the error.
Each implementation follows the same pattern.
The fx folders¶
The fx
folder must be the only folder importing and referencing Fx. It's meant to be a simple wrapper. Its only goal is to allow dependency injection with Fx for your component.
All fx.go
files must define a func Module() fxutil.Module
function. The helpers contained in fxutil
handle all the logic. Most fx/fx.go
file should look the same as this:
package fx
import (
"github.com/DataDog/datadog-agent/pkg/util/fxutil"
// You must import the implementation you are exposing through FX
compressionimpl "github.com/DataDog/datadog-agent/comp/compression/impl-zstd"
)
// Module specifies the compression module.
func Module() fxutil.Module {
return fxutil.Component(
// ProvideComponentConstructor will automatically detect the 'Requires' and 'Provides' structs
// of your constructor function and map them to FX.
fxutil.ProvideComponentConstructor(
compressionimpl.NewComponent,
)
)
}
Optional dependencies
To create an optional wrapper type for your component, you can use the helper function fxutil.ProvideOptional
. This generic function requires the type of the component interface, and will automatically make a conversion function optional.Option
for that component.
More on this in the FAQ.
For the ZIP implementation, create the same file in fx-zip
folder. In most cases, your component has a single implementation. If so, you have only one impl
and fx
folder.
fx-none
¶
Some parts of the codebase might have optional dependencies on your components (see FAQ).
If it's the case, you need to provide a fx wrapper called fx-none
to avoid duplicating the use of optional.NewNoneOption[def.Component]()
in all our binaries
import (
compression "github.com/DataDog/datadog-agent/comp/compression/def"
)
func Module() fxutil.Module {
return fxutil.Component(
fx.Provide(func() optional.Option[compression.Component] {
return optional.NewNoneOption[compression.Component]()
}))
}
The mock folder¶
To support testing, components MUST provide a mock implementation (unless your component has no public method in its interface).
Your mock must implement the Component
interface of the def
folder but can expose more methods if needed. All mock constructors must take a *testing.T
as parameter.
In the following example, your mock has no dependencies and returns the same string every time.
//go:build test
package mock
import (
"testing"
compression "github.com/DataDog/datadog-agent/comp/compression/def"
)
type Provides struct {
Comp compression.Component
}
type mock struct {}
// New returns a mock compressor
func New(*testing.T) Provides {
return Provides{
comp: &mock{},
}
}
// Compress compresses the input data using ZSTD
func (c *mock) Compress(data []byte) ([]byte, error) {
return []byte("compressed"), nil
}
// Decompress decompresses the input data using ZSTD.
func (c *compressor) Decompress(data []byte) ([]byte, error) {
return []byte("decompressed"), nil
}
Go module¶
Go modules are not mandatory, but if you want to allow your component to be used outside the datadog-agent
repository, create Go modules in the following places:
- In the
impl
/impl-*
folder that you want to expose (you can only expose some implementations). - In the
def
folder to expose the interface - In the
mock
folder to expose the mock
Never add a Go module to the component folder (for example,comp/compression
) or any fx
folders.
Final state¶
In the end, a classic component folder should look like:
comp/<COMPONENT_NAME>/
├── def
│ └── component.go
├── fx
│ └── fx.go
├── impl
│ └── component.go
└── mock
└── mock.go
4 directories, 4 files
The example compression component, which has two implementations, looks like:
comp/core/compression/
├── def
│ └── component.go
├── fx-zip
│ └── fx.go
├── fx-zstd
│ └── fx.go
├── impl-zip
│ └── component.go
├── impl-zstd
│ └── component.go
└── mock
└── mock.go
6 directories, 6 files
This can seem like a lot for a single compression component, but this design answers the exponentially increasing complexity of the Agent ecosystem. Your component needs to behave correctly with many binaries composed of unique and shared components, outside repositories that want to pull only specific features, and everything in between.
Important
No components know how or where they will be used and MUST, therefore, respect all the rules above. It's a very common pattern for teams to work only on their use cases, thinking their code will not be used anywhere else. But customers want common behavior between all Datadog products (Agent, serverless, Agentless, Helm, Operator, etc.).
A key idea behind the component is to produce shareable and reusable code.
General consideration about designing components¶
Your component must:
- Be thread safe.
- Any public methods should be able to be used as soon as your constructor is called. It's OK if some do nothing or drop data as long as the Agent lifecycle is still in its init phase (see lifecycle section for more | TODO).
- Be clearly documented (see section below).
- Be tested.
Documentation¶
The documentation (both package-level and method-level) should include everything a user of the component needs to know. In particular, the documentation must address any assumptions that might lead to panic if violated by the user.
Detailed documentation of how to avoid bugs in using a component is an indicator of excessive complexity and should be treated as a bug. Simplifying the usage will improve the robustness of the Agent.
Documentation should include:
- Precise information on when each method may be called. Can methods be called concurrently?
-
Precise information about data ownership of passed values and returned values. Users can assume that any mutable value returned by a component will not be modified by the user or the component after it is returned. Similarly, any mutable value passed to a component will not be later modified, whether by the component or the caller. Any deviation from these defaults should be documented.
Note
It can be surprisingly hard to avoid mutating data -- for example,
append(..)
surprisingly mutates its first argument. It is also hard to detect these bugs, as they are often intermittent, cause silent data corruption, or introduce rare data races. Where performance is not an issue, prefer to copy mutable input and outputs to avoid any potential bugs. -
Precise information about goroutines and blocking. Users can assume that methods do not block indefinitely, so blocking methods should be documented as such. Methods that invoke callbacks should be clear about how the callback is invoked, and what it might do. For example, document whether the callback can block, and whether it might be called concurrently with other code.
- Precise information about channels. Is the channel buffered? What happens if the channel is not read from quickly enough, or if reading stops? Can the channel be closed by the sender, and if so, what does that mean?