Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
1 change: 1 addition & 0 deletions .github/workflows/utils/cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@
"multiclient",
"multimod",
"mycert",
"mycomponent",
"myconnector",
"myexporter",
"myextension",
Expand Down
64 changes: 53 additions & 11 deletions cmd/mdatagen/README.md
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
# Metadata Generator

<!-- status autogenerated section -->
| Status | |
| ------------- |-----------|
| Stability | [alpha]: metrics |
| Issues | [![Open issues](https://img.shields.io/github/issues-search/open-telemetry/opentelemetry-collector?query=is%3Aissue%20is%3Aopen%20label%3Acmd%2Fmdatagen%20&label=open&color=orange&logo=opentelemetry)](https://github.com/open-telemetry/opentelemetry-collector/issues?q=is%3Aopen+is%3Aissue+label%3Acmd%2Fmdatagen) [![Closed issues](https://img.shields.io/github/issues-search/open-telemetry/opentelemetry-collector?query=is%3Aissue%20is%3Aclosed%20label%3Acmd%2Fmdatagen%20&label=closed&color=blue&logo=opentelemetry)](https://github.com/open-telemetry/opentelemetry-collector/issues?q=is%3Aclosed+is%3Aissue+label%3Acmd%2Fmdatagen) |
| [Code Owners](https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/CONTRIBUTING.md#becoming-a-code-owner) | [@dmitryax](https://www.github.com/dmitryax) |

| Status | |
| -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Stability | [alpha]: metrics |
| Issues | [![Open issues](https://img.shields.io/github/issues-search/open-telemetry/opentelemetry-collector?query=is%3Aissue%20is%3Aopen%20label%3Acmd%2Fmdatagen%20&label=open&color=orange&logo=opentelemetry)](https://github.com/open-telemetry/opentelemetry-collector/issues?q=is%3Aopen+is%3Aissue+label%3Acmd%2Fmdatagen) [![Closed issues](https://img.shields.io/github/issues-search/open-telemetry/opentelemetry-collector?query=is%3Aissue%20is%3Aclosed%20label%3Acmd%2Fmdatagen%20&label=closed&color=blue&logo=opentelemetry)](https://github.com/open-telemetry/opentelemetry-collector/issues?q=is%3Aclosed+is%3Aissue+label%3Acmd%2Fmdatagen) |
| [Code Owners](https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/main/CONTRIBUTING.md#becoming-a-code-owner) | [@dmitryax](https://www.github.com/dmitryax) |

[alpha]: https://github.com/open-telemetry/opentelemetry-collector/blob/main/docs/component-stability.md#alpha

<!-- end autogenerated section -->

Every component's documentation should include a brief description of the component and guidance on how to use it.
There is also some information about the component (or metadata) that should be included to help end-users understand the current state of the component and whether it is right for their use case.
Examples of this metadata about a component are:

* its stability level
* the distributions containing it
* the types of pipelines it supports
* metrics emitted in the case of a scraping receiver, a scraper, or a connector
- its stability level
- the distributions containing it
- the types of pipelines it supports
- metrics emitted in the case of a scraping receiver, a scraper, or a connector

The metadata generator defines a schema for specifying this information to ensure it is complete and well-formed.
The metadata generator is then able to ingest the metadata, validate it against the schema and produce documentation in a standardized format.
Expand All @@ -26,10 +28,12 @@ An example of how this generated documentation looks can be found in [documentat
## Using the Metadata Generator

In order for a component to benefit from the metadata generator (`mdatagen`) these requirements need to be met:

1. A yaml file containing the metadata that needs to be included in the component
2. The component should declare a `go:generate mdatagen` directive which tells `mdatagen` what to generate

As an example, here is a minimal `metadata.yaml` for the [OTLP receiver](https://github.com/open-telemetry/opentelemetry-collector/tree/main/receiver/otlpreceiver):

```yaml
type: otlp
status:
Expand All @@ -42,6 +46,7 @@ status:
Detailed information about the schema of `metadata.yaml` can be found in [metadata-schema.yaml](./metadata-schema.yaml).

The `go:generate mdatagen` directive is usually defined in a `doc.go` file in the same package as the component, for example:

```go
//go:generate mdatagen metadata.yaml

Expand All @@ -50,11 +55,48 @@ package main

Below are some more examples that can be used for reference:

* The ElasticSearch receiver has an extensive [metadata.yaml](https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver/elasticsearchreceiver/metadata.yaml)
* The host metrics receiver has internal subcomponents, each with their own `metadata.yaml` and `doc.go`. See [cpuscraper](https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver/hostmetricsreceiver/internal/scraper/cpuscraper) for example.
- The ElasticSearch receiver has an extensive [metadata.yaml](https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver/elasticsearchreceiver/metadata.yaml)
- The host metrics receiver has internal subcomponents, each with their own `metadata.yaml` and `doc.go`. See [cpuscraper](https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver/hostmetricsreceiver/internal/scraper/cpuscraper) for example.

You can run `cd cmd/mdatagen && $(GOCMD) install .` to install the `mdatagen` tool in `GOBIN` and then run `mdatagen metadata.yaml` to generate documentation for a specific component or you can run `make generate` to generate documentation for all components.

### Feature Gates Documentation

The metadata generator supports automatic documentation generation for feature gates used by components. Feature gates are documented by adding a `feature_gates` section to your `metadata.yaml`:

```yaml
type: mycomponent
status:
class: receiver
stability:
beta: [metrics, traces]

feature_gates:
- id: mycomponent.newFeature
description: 'Enables new feature functionality that improves performance'
stage: alpha
from_version: 'v0.100.0'
reference_url: 'https://github.com/open-telemetry/opentelemetry-collector/issues/12345'

- id: mycomponent.stableFeature
description: 'A feature that has reached stability'
stage: stable
from_version: 'v0.90.0'
to_version: 'v0.95.0'
reference_url: 'https://github.com/open-telemetry/opentelemetry-collector/issues/11111'
```

This will generate a "Feature Gates" section in the component's `documentation.md` file with a table containing:

- **Feature Gate**: The gate identifier
- **Stage**: The lifecycle stage (alpha, beta, stable, deprecated)
- **Description**: Brief description of what the gate controls
- **From Version**: Version when the gate was introduced
- **To Version**: Version when stable/deprecated gates will be removed (if applicable)
- **Reference**: Link to additional contextual information

The feature gate definitions should correspond to actual gates registered in your component code using the [Feature Gates API](../../featuregate/README.md).

### Generate multiple metadata packages

By default, `mdatagen` will generate a package called `metadata` in the `internal` directory. If you want to generate a package with a different name, you can use the `generated_package_name` configuration field to provide an alternate name.
Expand Down
2 changes: 1 addition & 1 deletion cmd/mdatagen/internal/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ func run(ymlPath string) error {
}
}

if len(md.Metrics) != 0 || len(md.Telemetry.Metrics) != 0 || len(md.ResourceAttributes) != 0 || len(md.Events) != 0 { // if there's metrics or internal metrics or events, generate documentation for them
if len(md.Metrics) != 0 || len(md.Telemetry.Metrics) != 0 || len(md.ResourceAttributes) != 0 || len(md.Events) != 0 || len(md.FeatureGates) != 0 { // if there's metrics or internal metrics or events or feature gates, generate documentation for them
toGenerate[filepath.Join(tmplDir, "documentation.md.tmpl")] = filepath.Join(ymlDir, "documentation.md")
}

Expand Down
12 changes: 11 additions & 1 deletion cmd/mdatagen/internal/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ func TestRunContents(t *testing.T) {
wantGoleakSkip bool
wantGoleakSetup bool
wantGoleakTeardown bool
wantDocumentationGenerated bool
wantErr bool
wantOrderErr bool
wantAttributes []string
Expand Down Expand Up @@ -192,6 +193,13 @@ func TestRunContents(t *testing.T) {
wantComponentTestGenerated: true,
wantLogsGenerated: true,
},
{
yml: "feature_gates.yaml",
wantStatusGenerated: true,
wantReadmeGenerated: true,
wantComponentTestGenerated: true,
wantDocumentationGenerated: true,
},
{
yml: "with_conditional_attribute.yaml",
wantStatusGenerated: true,
Expand Down Expand Up @@ -301,7 +309,9 @@ foo
require.NoFileExists(t, filepath.Join(tmpdir, generatedPackageDir, "generated_telemetry.go"))
}

if !tt.wantMetricsGenerated && !tt.wantTelemetryGenerated && !tt.wantResourceAttributesGenerated && !tt.wantEventsGenerated {
if tt.wantDocumentationGenerated {
require.FileExists(t, filepath.Join(tmpdir, "documentation.md"))
} else if !tt.wantMetricsGenerated && !tt.wantTelemetryGenerated && !tt.wantResourceAttributesGenerated && !tt.wantEventsGenerated {
require.NoFileExists(t, filepath.Join(tmpdir, "documentation.md"))
}

Expand Down
1 change: 1 addition & 0 deletions cmd/mdatagen/internal/embedded_templates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ func TestEnsureTemplatesLoaded(t *testing.T) {
path.Join(rootDir, "telemetrytest.go.tmpl"): {},
path.Join(rootDir, "telemetrytest_test.go.tmpl"): {},
path.Join(rootDir, "helper.tmpl"): {},
path.Join(rootDir, "feature_gates.md.tmpl"): {},
}
count = 0
)
Expand Down
104 changes: 104 additions & 0 deletions cmd/mdatagen/internal/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ type Metadata struct {
Tests Tests `mapstructure:"tests"`
// PackageName is the name of the package where the component is defined.
PackageName string `mapstructure:"package_name"`
// FeatureGates that are managed by the component.
FeatureGates []FeatureGate `mapstructure:"feature_gates"`
// FeatureGates that are managed by the component.
}

func (md Metadata) GetCodeCovComponentID() string {
Expand Down Expand Up @@ -89,6 +92,10 @@ func (md *Metadata) Validate() error {
errs = errors.Join(errs, err)
}

if err := md.validateFeatureGates(); err != nil {
errs = errors.Join(errs, err)
}

return errs
}

Expand Down Expand Up @@ -270,6 +277,74 @@ func validateEvents(events map[EventName]Event, attributes map[AttributeName]Att
return errs
}

func (md *Metadata) validateFeatureGates() error {
var errs error
seen := make(map[FeatureGateID]bool)
idRegexp := regexp.MustCompile(`^[0-9a-zA-Z.]*$`)

// Validate that feature gates are sorted by ID
if !slices.IsSortedFunc(md.FeatureGates, func(a, b FeatureGate) int {
return strings.Compare(string(a.ID), string(b.ID))
}) {
errs = errors.Join(errs, errors.New("feature gates must be sorted by ID"))
}

for i, gate := range md.FeatureGates {
// Validate gate ID is not empty
if string(gate.ID) == "" {
errs = errors.Join(errs, fmt.Errorf("feature gate at index %d: ID cannot be empty", i))
continue
}

// Validate ID follows the allowed character pattern
if !idRegexp.MatchString(string(gate.ID)) {
errs = errors.Join(errs, fmt.Errorf(`feature gate "%v": ID contains invalid characters, must match ^[0-9a-zA-Z.]*$`, gate.ID))
}

// Check for duplicate IDs
if seen[gate.ID] {
errs = errors.Join(errs, fmt.Errorf(`feature gate "%v": duplicate ID`, gate.ID))
continue
}
seen[gate.ID] = true

// Validate gate has required fields
if gate.Description == "" {
errs = errors.Join(errs, fmt.Errorf(`feature gate "%v": description is required`, gate.ID))
}

// Validate that each feature gate has a reference link
if gate.ReferenceURL == "" {
errs = errors.Join(errs, fmt.Errorf(`feature gate "%v": reference_url is required`, gate.ID))
}

// Validate stage is one of the allowed values
validStages := map[FeatureGateStage]bool{
FeatureGateStageAlpha: true,
FeatureGateStageBeta: true,
FeatureGateStageStable: true,
FeatureGateStageDeprecated: true,
}
if !validStages[gate.Stage] {
errs = errors.Join(errs, fmt.Errorf(`feature gate "%v": invalid stage "%v", must be one of: alpha, beta, stable, deprecated`, gate.ID, gate.Stage))
}

// Validate version formats if provided
if gate.FromVersion != "" && !strings.HasPrefix(gate.FromVersion, "v") {
errs = errors.Join(errs, fmt.Errorf(`feature gate "%v": from_version "%v" must start with 'v'`, gate.ID, gate.FromVersion))
}
if gate.ToVersion != "" && !strings.HasPrefix(gate.ToVersion, "v") {
errs = errors.Join(errs, fmt.Errorf(`feature gate "%v": to_version "%v" must start with 'v'`, gate.ID, gate.ToVersion))
}

// Validate that stable/deprecated gates should have to_version
if (gate.Stage == FeatureGateStageStable || gate.Stage == FeatureGateStageDeprecated) && gate.ToVersion == "" {
errs = errors.Join(errs, fmt.Errorf(`feature gate "%v": to_version is required for %v stage gates`, gate.ID, gate.Stage))
}
}
return errs
}

type AttributeName string

// AttributeRequirementLevel defines the requirement level of an attribute.
Expand Down Expand Up @@ -521,3 +596,32 @@ type EntityAttributeRef struct {
// Ref is the reference to a resource attribute.
Ref AttributeName `mapstructure:"ref"`
}

// FeatureGateID represents the identifier for a feature gate.
type FeatureGateID string

// FeatureGateStage represents the lifecycle stage of a feature gate.
type FeatureGateStage string

const (
FeatureGateStageAlpha FeatureGateStage = "alpha"
FeatureGateStageBeta FeatureGateStage = "beta"
FeatureGateStageStable FeatureGateStage = "stable"
FeatureGateStageDeprecated FeatureGateStage = "deprecated"
)

// FeatureGate represents a feature gate definition in metadata.
type FeatureGate struct {
// ID is the unique identifier for the feature gate.
ID FeatureGateID `mapstructure:"id"`
// Description of the feature gate.
Description string `mapstructure:"description"`
// Stage is the lifecycle stage of the feature gate.
Stage FeatureGateStage `mapstructure:"stage"`
// FromVersion is the version when the feature gate was introduced.
FromVersion string `mapstructure:"from_version"`
// ToVersion is the version when the feature gate reached stable stage.
ToVersion string `mapstructure:"to_version"`
// ReferenceURL is the URL with contextual information about the feature gate.
ReferenceURL string `mapstructure:"reference_url"`
}
Loading
Loading