Files
telemetry/docs/rfcs/component-interfaces.md
4431297c8e [docs/rfc] Add component interface guidelines (#14532)
#### Description

Specifies the mechanics of our approach to safe interface evolution.

See  #14523
See #14525
See #13902

#### Testing

This pattern was "tested" in #13241 as I prototyped a rate-limiter
extension.

#### Documentation

✅

---------

Signed-off-by: Alex Boten <[email protected]>
Co-authored-by: Alex Boten <[email protected]>
Co-authored-by: Pablo Baeyens <[email protected]>
2026-04-22 00:20:46 +00:00

12 KiB

Component Interface Guidelines

Overview

The OpenTelemetry Collector has a number of public interfaces and extension points that require careful attention to avoid breaking changes for users.

These guidelines describe how to achieve safe interface evolution in Golang. This approach is recommended for all Golang modules that want a safe approach to interface evolution.

When an interface type is exported for users outside of this repository, the type MUST follow these guidelines. This does not apply to internal packages, by definition.

Quick Reference

Aspect Sealed Interface Open Interface
Purpose Package-provided implementation Capability detection
private() method Yes No
Constructor Required (NewX) Not used
External implementation Prevented Encouraged
Evolution Add methods + options Add companion interfaces
Example receiver.Factory extensionmiddleware.GRPCClient

Background

As its most prominent feature, for every method in the public interface, a corresponding type <Method>Func func(...) ... declaration in the same package will exist. The method and the function have the same signature, by design.

The motivation for this pattern is to support safe, incremental evolution of public interfaces. By pairing each interface method with a corresponding function type, we gain important properties: callers can pass nil for no-op behavior, types can embed the function type to automatically satisfy the interface, and new methods can be added to sealed interfaces through new function types and constructor options—all without breaking existing code.

Users of the net/http package have seen this pattern. http.HandlerFunc can be seen as a prototype for this pattern, in this case for HTTP servers. The public interface type http.Handler:

// A Handler responds to an HTTP request.
type Handler interface {
  ServeHTTP(ResponseWriter, *Request)
}

has a corresponding function type:

// The HandlerFunc type is an adapter to allow the use of
// ordinary functions as HTTP handlers. If f is a function
// with the appropriate signature, HandlerFunc(f) is a
// [Handler] that calls f.
type HandlerFunc func(ResponseWriter, *Request)

// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
  f(w, r)
}

We use this pattern extensively, however there are important differences with ours and this specific case:

  • Public interfaces can only refer to other public interfaces, not concrete types. The *Request (pointer-to-struct) is not compatible with safe interface evolution.
  • Every function type must have a simple "no-op" behavior corresponding with its nil value. The Golang HandlerFunc implementation does not have a no-op implementation (callers are required to pass func(http.ResponseWriter, *http.Request) {} for no-op behavior).

In our version of this, the implementation is required to check for nil and expose only other interfaces for parameter- and return-types, themselves subject to the same safety requirements.

// SAFE VERSION satisfies requirements because arguments are interfaces
// and nil is checked.
//
// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r Request) {
    if f == nil {
        return
    }
  f(w, r)
}

The function type, in this pattern, corresponds with a single method of an exposed interface type and has several uses:

  • When a sealed-interface constructor takes <Method>Func as its argument, callers can pass nil for the no-op.
  • When an object embeds the <Method>Func, they inherit an implementation of the interface. Uninitialized types that embed <Method>Func automatically gain a no-op implementation of the associated method.

Interface Implementation Patterns

Public interfaces in the Collector fall into two categories: sealed interfaces and open interfaces.

Sealed interfaces are provided by a package with safe-evolution in mind, not for external implementations. The use of sealed interfaces allows a repository to stabilize public methods while ensuring the ability to add future methods by preventing external construction. External implementations are prohibited in this case. Examples include the Factory interface and NewFactory constructor used to register Collector components in each of the receiver, processor, exporter, connector, and extension sub-modules. In this example, new factory methods can be added in the future, through functional options, without breaking existing consumers or providers.

Open interfaces, also known as optional interfaces, are meant to enable extension points, allowing capability detection on a method-by-method basis. Open interfaces are implemented by components to provide capabilities discovered through type assertions. While it is not safe to add a new method to an open interface, as it will break existing implementations, it is safe to add new companion interfaces that serve equivalent functions. Examples include extensionmiddleware.HTTPClient and extensionmiddleware.GRPCServer, our middleware interfaces. In this example, new protocols can have middleware support added in the future, and we can adjust to changes in the existing HTTP and gRPC middleware.

Key Concepts

Two Categories of Public Interfaces

Public interfaces in the Collector fall into two categories based on how they are discovered and used.

Sealed interfaces are provided by a package and consumed by users. They use an unexported private() method to prevent external implementations, guaranteeing all implementations come through package-provided constructors. Following the guidance in "working with interfaces", this practice enables safely evolving interfaces because users are forced to use constructor functions. Examples include receiver.Factory and component.Host.

Open interfaces, also called optional interfaces, are implemented by components and discovered through type assertions at runtime. Consumers check if a component implements a capability using Go's type assertion syntax. Multiple independent implementations exist, and components "opt in" to providing a feature. Examples include extensionmiddleware.GRPCClient and extensionauth.Client.

The key distinction: sealed interfaces prevent external implementation; open interfaces invite it.

When to Seal an Interface

Interfaces MUST be sealed when the package provides the canonical implementation and needs guaranteed control over all instances. This is appropriate when interface evolution requires adding methods that all implementations must have, and when the Functional Option pattern will be used to extend capabilities over time.

Evolving Sealed Interfaces

Sealed interfaces evolve by adding new methods with corresponding function types and options. Because all implementations use package-provided constructors, new methods can be added safely.

The receiver.Factory interface demonstrates this pattern:

// Factory is sealed—external implementations prevented
type Factory interface {
    // Embed a sealed interface from another module
    component.Factory
    
    // Traces-specific methods
    CreateTraces(...) (Traces, error)
    TracesStability() component.StabilityLevel

    // ... additional methods for other signals

    unexportedFactoryFunc() // sealing method
}

// <Method>Func for CreateTraces
type CreateTracesFunc func(context.Context, Settings, component.Config, consumer.Traces) (Traces, error)

// Functional option for traces capability
func WithTraces(createTraces CreateTracesFunc, sl component.StabilityLevel) FactoryOption { ... }

// Constructor uses functional options
func NewFactory(cfgType component.Type, createDefaultConfig component.CreateDefaultConfigFunc, options ...FactoryOption) Factory

This pattern supports adding a new kind of signal to the interface, safely, for example by adding CreateProfiles(), CreateProfilesFunc, and WithProfiles.

If the functional option pattern is already in use, new interface methods require only new options. If there is not an existing functional option pattern, it is still safe to extend the interface by creating a new constructor.

Major interfaces SHOULD use the functional option pattern even when there are initially no options.

Evolving Open Interfaces

Open interfaces evolve by creating new companion interfaces for new capabilities. Open interfaces MUST remain unchanged until a major version bump, to preserve compatibility.

Imagine a new RPC framework is introduced, with a new kind of client configuration that middleware extensions can implement. We cannot extend the existing interfaces, but we can create new ones,

// Imagine a new RPC framework called "Super".
type SuperClient interface {
    GetSuperClientOptions(context.Context) ([]super.ClientOption, error)
}

We can also accommodate new interface types corresponding with existing frameworks. For example, if gRPC decides to add a new sort of configuration type, we can add an optional new interface,

// Imagine a V2 gRPC option type.
type GRPCClientV2 interface {
    GetGRPCClientOptionsV2(context.Context) ([]grpc.ClientOptionV2, error)
}

The new middleware extension can be added without changing the existing implementations, because only a user of the new framework needs to know about the new kind of middleware.

This works because components generally know which specific extension interface they want. When there is more than one viable extension interface, callers can choose the one they prefer depending on the use.

Test Helpers with NewNop and NewErr

Every public and extension interface package SHOULD provide test helpers in a <package>test subpackage using a dedicated Go module (go.mod). These helpers generally can be composed of a <Method>Func for every method in the extension.

For example, here is a type for use in testing that implements every public interface method, making it easy for tests to override only one method.

type baseExtension struct {
    component.StartFunc
    component.ShutdownFunc
    extensionmiddleware.GetHTTPRoundTripperFunc
    extensionmiddleware.GetGRPCClientOptionsFunc
    extensionmiddleware.GetGRPCClientOptionsContextFunc

    // ... new middleware interfaces can be added using the corresponding
    // <Method>Funcs.
}

Two constructors are typically provided for standard testing:

NewNop() returns an extension where all methods have no-op behavior. Because nil function types return zero values, &baseExtension{} is a valid do-nothing implementation.

NewErr(err error) returns an extension where all methods return the specified error, enabling testing of error handling paths.

Examples

The extensionmiddleware package demonstrates this pattern with open interfaces for capability detection:

References