diff --git a/.chloggen/systemd-receiver.yaml b/.chloggen/systemd-receiver.yaml new file mode 100644 index 0000000000000..ff1459d957164 --- /dev/null +++ b/.chloggen/systemd-receiver.yaml @@ -0,0 +1,27 @@ +# Use this changelog template to create an entry for release notes. + +# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix' +change_type: new_component + +# The name of the component, or a single word describing the area of concern, (e.g. receiver/filelog) +component: receiver/systemd + +# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`). +note: Report active state of systemd units. + +# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists. +issues: [33532] + +# (Optional) One or more lines of additional information to render under the primary note. +# These lines will be padded with 2 spaces and then inserted directly into the document. +# Use pipe (|) for multiline entries. +subtext: + +# If your change doesn't affect end users or the exported elements of any package, +# you should instead start your pull request title with [chore] or use the "Skip Changelog" label. +# Optional: The change log or logs in which this entry should be included. +# e.g. '[user]' or '[user, api]' +# Include 'user' if the change is relevant to end users. +# Include 'api' if there is a change to a library API. +# Default: '[user]' +change_logs: [user] diff --git a/receiver/systemdreceiver/README.md b/receiver/systemdreceiver/README.md index 3b8f319aea5f9..78ba538f87fb2 100644 --- a/receiver/systemdreceiver/README.md +++ b/receiver/systemdreceiver/README.md @@ -4,6 +4,7 @@ | Status | | | ------------- |-----------| | Stability | [development]: metrics | +| Unsupported Platforms | darwin, windows | | Distributions | [] | | Issues | [![Open issues](https://img.shields.io/github/issues-search/open-telemetry/opentelemetry-collector-contrib?query=is%3Aissue%20is%3Aopen%20label%3Areceiver%2Fsystemd%20&label=open&color=orange&logo=opentelemetry)](https://github.com/open-telemetry/opentelemetry-collector-contrib/issues?q=is%3Aopen+is%3Aissue+label%3Areceiver%2Fsystemd) [![Closed issues](https://img.shields.io/github/issues-search/open-telemetry/opentelemetry-collector-contrib?query=is%3Aissue%20is%3Aclosed%20label%3Areceiver%2Fsystemd%20&label=closed&color=blue&logo=opentelemetry)](https://github.com/open-telemetry/opentelemetry-collector-contrib/issues?q=is%3Aclosed+is%3Aissue+label%3Areceiver%2Fsystemd) | | Code coverage | [![codecov](https://codecov.io/github/open-telemetry/opentelemetry-collector-contrib/graph/main/badge.svg?component=receiver_systemd)](https://app.codecov.io/gh/open-telemetry/opentelemetry-collector-contrib/tree/main/?components%5B0%5D=receiver_systemd&displayType=list) | @@ -13,3 +14,27 @@ [development]: https://github.com/open-telemetry/opentelemetry-collector/blob/main/docs/component-stability.md#development +The systemd receiver gathers metrics for locally running systemd units. + +This scraper generates a metric with a label for service state with a value of `1` if the unit is in that state. For +example, the following metrics will be generated if the `nginx` service is currently active: + +``` +systemd.unit.state{systemd.unit.name="nginx", systemd.unit.active_state="active"} = 1 +systemd.unit.state{systemd.unit.name="nginx", systemd.unit.active_state="reloading"} = 0 +systemd.unit.state{systemd.unit.name="nginx", systemd.unit.active_state="inactive"} = 0 +systemd.unit.state{systemd.unit.name="nginx", systemd.unit.active_state="failed"} = 0 +systemd.unit.state{systemd.unit.name="nginx", systemd.unit.active_state="activating"} = 0 +systemd.unit.state{systemd.unit.name="nginx", systemd.unit.active_state="deactivating"} = 0 +systemd.unit.state{systemd.unit.name="nginx", systemd.unit.active_state="maintenance"} = 0 +systemd.unit.state{systemd.unit.name="nginx", systemd.unit.active_state="refreshing"} = 0 +``` + +## Configuration + +| Field | Default | Description | +|---------| -----------------| ---------------------------------------------------------------------| +| `scope` | `system` | The service manager to gather units from, either `system` or `user`. | +| `units` | `["*.service"]` | The units to scrape, as a list of [patterns]. | + +[patterns]: https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#Parameter%20Syntax diff --git a/receiver/systemdreceiver/config.go b/receiver/systemdreceiver/config.go index 999c1fbc54f6c..aea831c764aa3 100644 --- a/receiver/systemdreceiver/config.go +++ b/receiver/systemdreceiver/config.go @@ -3,4 +3,39 @@ package systemdreceiver // import "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/systemdreceiver" -type Config struct{} +import ( + "errors" + + "go.opentelemetry.io/collector/scraper/scraperhelper" + "go.uber.org/multierr" + + "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/systemdreceiver/internal/metadata" +) + +var ( + errInvalidScope = errors.New(`"scope" must be one of "systemd" or "user"`) + errNoUnits = errors.New("no units configured") +) + +type Config struct { + scraperhelper.ControllerConfig `mapstructure:",squash"` + metadata.MetricsBuilderConfig `mapstructure:",squash"` + + Scope string `mapstructure:"scope"` + Units []string `mapstructure:"units"` +} + +func (c Config) Validate() error { + var err error + + // Ensure we have a valid scope. + if c.Scope != "system" && c.Scope != "user" { + err = multierr.Append(err, errInvalidScope) + } + + if len(c.Units) == 0 { + err = multierr.Append(err, errNoUnits) + } + + return err +} diff --git a/receiver/systemdreceiver/config_test.go b/receiver/systemdreceiver/config_test.go new file mode 100644 index 0000000000000..a8df0c2433619 --- /dev/null +++ b/receiver/systemdreceiver/config_test.go @@ -0,0 +1,54 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package systemdreceiver + +import ( + "testing" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/scraper/scraperhelper" + "go.uber.org/multierr" + + "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/systemdreceiver/internal/metadata" +) + +func TestValidate(t *testing.T) { + testCases := []struct { + desc string + cfg *Config + expectedErr error + }{ + { + desc: "invalid scope", + cfg: &Config{ + Scope: "unknown", + Units: []string{"*.service"}, + ControllerConfig: scraperhelper.NewDefaultControllerConfig(), + MetricsBuilderConfig: metadata.DefaultMetricsBuilderConfig(), + }, + expectedErr: multierr.Combine(errInvalidScope), + }, + { + desc: "no units", + cfg: &Config{ + Scope: "system", + Units: []string{}, + ControllerConfig: scraperhelper.NewDefaultControllerConfig(), + MetricsBuilderConfig: metadata.DefaultMetricsBuilderConfig(), + }, + expectedErr: multierr.Combine(errNoUnits), + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + actualErr := tc.cfg.Validate() + if tc.expectedErr != nil { + require.EqualError(t, actualErr, tc.expectedErr.Error()) + } else { + require.NoError(t, actualErr) + } + }) + } +} diff --git a/receiver/systemdreceiver/documentation.md b/receiver/systemdreceiver/documentation.md new file mode 100644 index 0000000000000..1bfea333e0cc1 --- /dev/null +++ b/receiver/systemdreceiver/documentation.md @@ -0,0 +1,33 @@ +[comment]: <> (Code generated by mdatagen. DO NOT EDIT.) + +# systemd + +## Default Metrics + +The following metrics are emitted by default. Each of them can be disabled by applying the following configuration: + +```yaml +metrics: + : + enabled: false +``` + +### systemd.unit.state + +1 if the check resulted in active_state matching the current state, otherwise 0. + +| Unit | Metric Type | Value Type | Aggregation Temporality | Monotonic | +| ---- | ----------- | ---------- | ----------------------- | --------- | +| 1 | Sum | Int | Cumulative | false | + +#### Attributes + +| Name | Description | Values | Requirement Level | +| ---- | ----------- | ------ | -------- | +| systemd.unit.active_state | The active state of the unit (https://www.freedesktop.org/software/systemd/man/latest/systemd.html#Units) | Str: ``active``, ``reloading``, ``inactive``, ``failed``, ``activating``, ``deactivating``, ``maintenance``, ``refreshing`` | Recommended | + +## Resource Attributes + +| Name | Description | Values | Enabled | +| ---- | ----------- | ------ | ------- | +| systemd.unit.name | Name of the systemd unit | Any Str | true | diff --git a/receiver/systemdreceiver/factory.go b/receiver/systemdreceiver/factory.go index 0891b5652978c..fc64ddac3c1b3 100644 --- a/receiver/systemdreceiver/factory.go +++ b/receiver/systemdreceiver/factory.go @@ -5,14 +5,24 @@ package systemdreceiver // import "github.com/open-telemetry/opentelemetry-colle import ( "context" + "errors" + "runtime" + "time" "go.opentelemetry.io/collector/component" "go.opentelemetry.io/collector/consumer" "go.opentelemetry.io/collector/receiver" + "go.opentelemetry.io/collector/scraper" + "go.opentelemetry.io/collector/scraper/scraperhelper" "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/systemdreceiver/internal/metadata" ) +var ( + errConfigNotValid = errors.New("config not valid for the systemd receiver") + errNonLinux = errors.New("systemd receiver is only supported on Linux") +) + // NewFactory creates a factory for systemd receiver. func NewFactory() receiver.Factory { return receiver.NewFactory( @@ -22,14 +32,32 @@ func NewFactory() receiver.Factory { } func createDefaultConfig() component.Config { - return &Config{} + cfg := scraperhelper.NewDefaultControllerConfig() + cfg.CollectionInterval = 60 * time.Second + + return &Config{ + ControllerConfig: cfg, + MetricsBuilderConfig: metadata.DefaultMetricsBuilderConfig(), + Scope: "system", + Units: []string{"*.service"}, + } } -func createMetricsReceiver( - _ context.Context, - _ receiver.Settings, - _ component.Config, - _ consumer.Metrics, -) (receiver.Metrics, error) { - return systemdReceiver{}, nil +func createMetricsReceiver(_ context.Context, params receiver.Settings, rConf component.Config, consumer consumer.Metrics) (receiver.Metrics, error) { + if runtime.GOOS != "linux" { + return nil, errNonLinux + } + + cfg, ok := rConf.(*Config) + if !ok { + return nil, errConfigNotValid + } + + systemdScraper := newScraper(cfg, params) + s, err := scraper.NewMetrics(systemdScraper.scrape, scraper.WithStart(systemdScraper.start), scraper.WithShutdown(systemdScraper.shutdown)) + if err != nil { + return nil, err + } + + return scraperhelper.NewMetricsController(&cfg.ControllerConfig, params, consumer, scraperhelper.AddScraper(metadata.Type, s)) } diff --git a/receiver/systemdreceiver/factory_test.go b/receiver/systemdreceiver/factory_test.go new file mode 100644 index 0000000000000..4550a02bfc4f7 --- /dev/null +++ b/receiver/systemdreceiver/factory_test.go @@ -0,0 +1,43 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package systemdreceiver + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/collector/component/componenttest" + "go.opentelemetry.io/collector/receiver/receivertest" + + "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/systemdreceiver/internal/metadata" +) + +func TestCreateDefaultConfig(t *testing.T) { + factory := NewFactory() + cfg := factory.CreateDefaultConfig() + assert.IsType(t, &Config{}, cfg) + assert.NotNil(t, cfg, "failed to create default config") + assert.NoError(t, componenttest.CheckConfigStruct(cfg)) +} + +func TestCreateMetrics(t *testing.T) { + factory := NewFactory() + + scraper, err := factory.CreateMetrics( + t.Context(), + receivertest.NewNopSettings(metadata.Type), + factory.CreateDefaultConfig(), + nil, + ) + + if runtime.GOOS == "linux" { + assert.NoError(t, err) + assert.NotNil(t, scraper) + } else { + assert.Error(t, err) + assert.Equal(t, errNonLinux, err) + assert.Nil(t, scraper) + } +} diff --git a/receiver/systemdreceiver/generated_component_test.go b/receiver/systemdreceiver/generated_component_test.go index a0c96f6bb1930..a09894dcb7201 100644 --- a/receiver/systemdreceiver/generated_component_test.go +++ b/receiver/systemdreceiver/generated_component_test.go @@ -1,4 +1,5 @@ // Code generated by mdatagen. DO NOT EDIT. +//go:build !darwin && !windows package systemdreceiver diff --git a/receiver/systemdreceiver/go.mod b/receiver/systemdreceiver/go.mod index e5b9e6e34b7de..f7088206b7cdf 100644 --- a/receiver/systemdreceiver/go.mod +++ b/receiver/systemdreceiver/go.mod @@ -3,18 +3,29 @@ module github.com/open-telemetry/opentelemetry-collector-contrib/receiver/system go 1.24.0 require ( + github.com/godbus/dbus/v5 v5.1.0 + github.com/google/go-cmp v0.7.0 + github.com/open-telemetry/opentelemetry-collector-contrib/pkg/golden v0.138.0 + github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatatest v0.133.0 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/collector/component v1.44.1-0.20251021231522-c657d5d4e920 go.opentelemetry.io/collector/component/componenttest v0.138.1-0.20251021231522-c657d5d4e920 go.opentelemetry.io/collector/confmap v1.44.1-0.20251021231522-c657d5d4e920 go.opentelemetry.io/collector/consumer v1.44.1-0.20251021231522-c657d5d4e920 go.opentelemetry.io/collector/consumer/consumertest v0.138.1-0.20251021231522-c657d5d4e920 + go.opentelemetry.io/collector/filter v0.138.1-0.20251021231522-c657d5d4e920 + go.opentelemetry.io/collector/pdata v1.44.1-0.20251021231522-c657d5d4e920 go.opentelemetry.io/collector/receiver v1.44.1-0.20251021231522-c657d5d4e920 go.opentelemetry.io/collector/receiver/receivertest v0.138.1-0.20251021231522-c657d5d4e920 + go.opentelemetry.io/collector/scraper v0.138.1-0.20251021231522-c657d5d4e920 + go.opentelemetry.io/collector/scraper/scraperhelper v0.138.1-0.20251021231522-c657d5d4e920 go.uber.org/goleak v1.3.0 + go.uber.org/multierr v1.11.0 + go.uber.org/zap v1.27.0 ) require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -31,15 +42,16 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatautil v0.138.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/collector/consumer/consumererror v0.138.1-0.20251021231522-c657d5d4e920 // indirect go.opentelemetry.io/collector/consumer/xconsumer v0.138.1-0.20251021231522-c657d5d4e920 // indirect go.opentelemetry.io/collector/featuregate v1.44.1-0.20251021231522-c657d5d4e920 // indirect go.opentelemetry.io/collector/internal/telemetry v0.138.1-0.20251021231522-c657d5d4e920 // indirect - go.opentelemetry.io/collector/pdata v1.44.1-0.20251021231522-c657d5d4e920 // indirect go.opentelemetry.io/collector/pdata/pprofile v0.138.1-0.20251021231522-c657d5d4e920 // indirect go.opentelemetry.io/collector/pipeline v1.44.1-0.20251021231522-c657d5d4e920 // indirect + go.opentelemetry.io/collector/receiver/receiverhelper v0.138.1-0.20251021231522-c657d5d4e920 // indirect go.opentelemetry.io/collector/receiver/xreceiver v0.138.1-0.20251021231522-c657d5d4e920 // indirect go.opentelemetry.io/contrib/bridges/otelzap v0.13.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect @@ -48,8 +60,6 @@ require ( go.opentelemetry.io/otel/sdk v1.38.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect - go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sys v0.35.0 // indirect @@ -65,3 +75,9 @@ retract ( v0.76.1 v0.65.0 ) + +replace github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatatest => ../../pkg/pdatatest + +replace github.com/open-telemetry/opentelemetry-collector-contrib/pkg/golden => ../../pkg/golden + +replace github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatautil => ../../pkg/pdatautil diff --git a/receiver/systemdreceiver/go.sum b/receiver/systemdreceiver/go.sum index 1e359c027219a..71f84bbe3157c 100644 --- a/receiver/systemdreceiver/go.sum +++ b/receiver/systemdreceiver/go.sum @@ -1,3 +1,5 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -10,6 +12,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -73,22 +77,30 @@ go.opentelemetry.io/collector/consumer/xconsumer v0.138.1-0.20251021231522-c657d go.opentelemetry.io/collector/consumer/xconsumer v0.138.1-0.20251021231522-c657d5d4e920/go.mod h1:ivpzDlwQowx8RTOZBPa281/4NvNBvhabm7JmeAbsGIU= go.opentelemetry.io/collector/featuregate v1.44.1-0.20251021231522-c657d5d4e920 h1:XgFQhGa8BCImTziEUp6C3Pv9lUpi2Iezjlu6hHagR4s= go.opentelemetry.io/collector/featuregate v1.44.1-0.20251021231522-c657d5d4e920/go.mod h1:d0tiRzVYrytB6LkcYgz2ESFTv7OktRPQe0QEQcPt1L4= +go.opentelemetry.io/collector/filter v0.138.1-0.20251021231522-c657d5d4e920 h1:5FC/qypTQN0ENb8pYYK5MvxXB1xmx3ct5gjHo+vutP0= +go.opentelemetry.io/collector/filter v0.138.1-0.20251021231522-c657d5d4e920/go.mod h1:vLSR5hAupTYEDk0Cqh8eu7TA76bKIZTxxwdxuAzxXN0= go.opentelemetry.io/collector/internal/telemetry v0.138.1-0.20251021231522-c657d5d4e920 h1:H5lx5f/JjmBLg/He9DIkJ4OA+1XSUGH3snMVJQ7q0xY= go.opentelemetry.io/collector/internal/telemetry v0.138.1-0.20251021231522-c657d5d4e920/go.mod h1:evqf71fdIMXdQEofbs1bVnBUzfF6zysLMLR9bEAS9Xw= go.opentelemetry.io/collector/pdata v1.44.1-0.20251021231522-c657d5d4e920 h1:gHOsEOBVqTsjIbVm5Z0NmJyu6l/Oqss7sXCpLDFm/us= go.opentelemetry.io/collector/pdata v1.44.1-0.20251021231522-c657d5d4e920/go.mod h1:LnsjYysFc3AwMVh6KGNlkGKJUF2ReuWxtD9Hb3lSMZk= go.opentelemetry.io/collector/pdata/pprofile v0.138.1-0.20251021231522-c657d5d4e920 h1:J8eTmXj0AuQ2u0IYyjXOg+UbQg0u6Xw5zQ6MR7kYOBk= go.opentelemetry.io/collector/pdata/pprofile v0.138.1-0.20251021231522-c657d5d4e920/go.mod h1:M7/5+Q4LohEkEB38kHhFu3S3XCA1eGSGz5uSXvNyMlM= -go.opentelemetry.io/collector/pdata/testdata v0.138.0 h1:6geeGQ4Rsb88OARLcACKn09PVIbhExaNJ1aC9OVLZaw= -go.opentelemetry.io/collector/pdata/testdata v0.138.0/go.mod h1:4wvgY+KTP7ohJVd1/pb8UIKb2TA/girsZbGTKqM5e20= +go.opentelemetry.io/collector/pdata/testdata v0.138.1-0.20251021231522-c657d5d4e920 h1:LoO/jjXf5LAVqOcHRRSYgrdDLKUHRagtJzdtKcreebY= +go.opentelemetry.io/collector/pdata/testdata v0.138.1-0.20251021231522-c657d5d4e920/go.mod h1:4wvgY+KTP7ohJVd1/pb8UIKb2TA/girsZbGTKqM5e20= go.opentelemetry.io/collector/pipeline v1.44.1-0.20251021231522-c657d5d4e920 h1:gt90VoHXMOoS1cLgboWPFAzBklsxRYdvvVnB01PaL1o= go.opentelemetry.io/collector/pipeline v1.44.1-0.20251021231522-c657d5d4e920/go.mod h1:xUrAqiebzYbrgxyoXSkk6/Y3oi5Sy3im2iCA51LwUAI= go.opentelemetry.io/collector/receiver v1.44.1-0.20251021231522-c657d5d4e920 h1:0XxhTZRyEiR9UqoAxNCtKgaZ8iwTWI1LdhBImbygmkg= go.opentelemetry.io/collector/receiver v1.44.1-0.20251021231522-c657d5d4e920/go.mod h1:NzkrGOIoWigOG54eF92ZGfJ8oSWhqGHTT0ZCGaH5NMc= +go.opentelemetry.io/collector/receiver/receiverhelper v0.138.1-0.20251021231522-c657d5d4e920 h1:69Njt4fW3Yj4xjVmfFIPqRGRB/7noXia7QjItKid0Y4= +go.opentelemetry.io/collector/receiver/receiverhelper v0.138.1-0.20251021231522-c657d5d4e920/go.mod h1:WxMvaPgL9MWrIKjDiZ/SmopEXAX+sO9CD/SfXI9J63A= go.opentelemetry.io/collector/receiver/receivertest v0.138.1-0.20251021231522-c657d5d4e920 h1:31yS4582QPyBSLcJFP3ENma4C7Qek+LIue4Z13ONNcs= go.opentelemetry.io/collector/receiver/receivertest v0.138.1-0.20251021231522-c657d5d4e920/go.mod h1:p3cGSplwwp71r7R6u0e8N0rP/mmPsFjJ4WFV2Bhv7os= go.opentelemetry.io/collector/receiver/xreceiver v0.138.1-0.20251021231522-c657d5d4e920 h1:lIdt+ZOYmTT8Wj0zwLHtv1fSJ426wX6R9HfBfeWavfs= go.opentelemetry.io/collector/receiver/xreceiver v0.138.1-0.20251021231522-c657d5d4e920/go.mod h1:+S/AsbEs1geUt3B+HAhdSjd+3hPkjtmcSBltKwpCBik= +go.opentelemetry.io/collector/scraper v0.138.1-0.20251021231522-c657d5d4e920 h1:f5/AjaMNIZgZf4+WHwWmcq7aFG5xN0qwtAU0hZqJHKc= +go.opentelemetry.io/collector/scraper v0.138.1-0.20251021231522-c657d5d4e920/go.mod h1:9xp6yYvAeH8KGn8cJAtcRZ7IRN1r0k40yS5LfhHPzPg= +go.opentelemetry.io/collector/scraper/scraperhelper v0.138.1-0.20251021231522-c657d5d4e920 h1:WHWzedETUo3nsAvZ6VhaC3KxeLEag7L2zjxqigYRsTw= +go.opentelemetry.io/collector/scraper/scraperhelper v0.138.1-0.20251021231522-c657d5d4e920/go.mod h1:pMheZcc1qK6fXUYlHIj+Ik8fL1v2mL3n9CUmH9NVzaA= go.opentelemetry.io/contrib/bridges/otelzap v0.13.0 h1:aBKdhLVieqvwWe9A79UHI/0vgp2t/s2euY8X59pGRlw= go.opentelemetry.io/contrib/bridges/otelzap v0.13.0/go.mod h1:SYqtxLQE7iINgh6WFuVi2AI70148B8EI35DSk0Wr8m4= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= diff --git a/receiver/systemdreceiver/internal/metadata/generated_config.go b/receiver/systemdreceiver/internal/metadata/generated_config.go new file mode 100644 index 0000000000000..518f36ad2cde8 --- /dev/null +++ b/receiver/systemdreceiver/internal/metadata/generated_config.go @@ -0,0 +1,92 @@ +// Code generated by mdatagen. DO NOT EDIT. + +package metadata + +import ( + "go.opentelemetry.io/collector/confmap" + "go.opentelemetry.io/collector/filter" +) + +// MetricConfig provides common config for a particular metric. +type MetricConfig struct { + Enabled bool `mapstructure:"enabled"` + + enabledSetByUser bool +} + +func (ms *MetricConfig) Unmarshal(parser *confmap.Conf) error { + if parser == nil { + return nil + } + err := parser.Unmarshal(ms) + if err != nil { + return err + } + ms.enabledSetByUser = parser.IsSet("enabled") + return nil +} + +// MetricsConfig provides config for systemd metrics. +type MetricsConfig struct { + SystemdUnitState MetricConfig `mapstructure:"systemd.unit.state"` +} + +func DefaultMetricsConfig() MetricsConfig { + return MetricsConfig{ + SystemdUnitState: MetricConfig{ + Enabled: true, + }, + } +} + +// ResourceAttributeConfig provides common config for a particular resource attribute. +type ResourceAttributeConfig struct { + Enabled bool `mapstructure:"enabled"` + // Experimental: MetricsInclude defines a list of filters for attribute values. + // If the list is not empty, only metrics with matching resource attribute values will be emitted. + MetricsInclude []filter.Config `mapstructure:"metrics_include"` + // Experimental: MetricsExclude defines a list of filters for attribute values. + // If the list is not empty, metrics with matching resource attribute values will not be emitted. + // MetricsInclude has higher priority than MetricsExclude. + MetricsExclude []filter.Config `mapstructure:"metrics_exclude"` + + enabledSetByUser bool +} + +func (rac *ResourceAttributeConfig) Unmarshal(parser *confmap.Conf) error { + if parser == nil { + return nil + } + err := parser.Unmarshal(rac) + if err != nil { + return err + } + rac.enabledSetByUser = parser.IsSet("enabled") + return nil +} + +// ResourceAttributesConfig provides config for systemd resource attributes. +type ResourceAttributesConfig struct { + SystemdUnitName ResourceAttributeConfig `mapstructure:"systemd.unit.name"` +} + +func DefaultResourceAttributesConfig() ResourceAttributesConfig { + return ResourceAttributesConfig{ + SystemdUnitName: ResourceAttributeConfig{ + Enabled: true, + }, + } +} + +// MetricsBuilderConfig is a configuration for systemd metrics builder. +type MetricsBuilderConfig struct { + Metrics MetricsConfig `mapstructure:"metrics"` + ResourceAttributes ResourceAttributesConfig `mapstructure:"resource_attributes"` +} + +func DefaultMetricsBuilderConfig() MetricsBuilderConfig { + return MetricsBuilderConfig{ + Metrics: DefaultMetricsConfig(), + ResourceAttributes: DefaultResourceAttributesConfig(), + } +} diff --git a/receiver/systemdreceiver/internal/metadata/generated_config_test.go b/receiver/systemdreceiver/internal/metadata/generated_config_test.go new file mode 100644 index 0000000000000..8f2a03d28b6bc --- /dev/null +++ b/receiver/systemdreceiver/internal/metadata/generated_config_test.go @@ -0,0 +1,109 @@ +// Code generated by mdatagen. DO NOT EDIT. + +package metadata + +import ( + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/stretchr/testify/require" + + "go.opentelemetry.io/collector/confmap" + "go.opentelemetry.io/collector/confmap/confmaptest" +) + +func TestMetricsBuilderConfig(t *testing.T) { + tests := []struct { + name string + want MetricsBuilderConfig + }{ + { + name: "default", + want: DefaultMetricsBuilderConfig(), + }, + { + name: "all_set", + want: MetricsBuilderConfig{ + Metrics: MetricsConfig{ + SystemdUnitState: MetricConfig{Enabled: true}, + }, + ResourceAttributes: ResourceAttributesConfig{ + SystemdUnitName: ResourceAttributeConfig{Enabled: true}, + }, + }, + }, + { + name: "none_set", + want: MetricsBuilderConfig{ + Metrics: MetricsConfig{ + SystemdUnitState: MetricConfig{Enabled: false}, + }, + ResourceAttributes: ResourceAttributesConfig{ + SystemdUnitName: ResourceAttributeConfig{Enabled: false}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := loadMetricsBuilderConfig(t, tt.name) + diff := cmp.Diff(tt.want, cfg, cmpopts.IgnoreUnexported(MetricConfig{}, ResourceAttributeConfig{})) + require.Emptyf(t, diff, "Config mismatch (-expected +actual):\n%s", diff) + }) + } +} + +func loadMetricsBuilderConfig(t *testing.T, name string) MetricsBuilderConfig { + cm, err := confmaptest.LoadConf(filepath.Join("testdata", "config.yaml")) + require.NoError(t, err) + sub, err := cm.Sub(name) + require.NoError(t, err) + cfg := DefaultMetricsBuilderConfig() + require.NoError(t, sub.Unmarshal(&cfg, confmap.WithIgnoreUnused())) + return cfg +} + +func TestResourceAttributesConfig(t *testing.T) { + tests := []struct { + name string + want ResourceAttributesConfig + }{ + { + name: "default", + want: DefaultResourceAttributesConfig(), + }, + { + name: "all_set", + want: ResourceAttributesConfig{ + SystemdUnitName: ResourceAttributeConfig{Enabled: true}, + }, + }, + { + name: "none_set", + want: ResourceAttributesConfig{ + SystemdUnitName: ResourceAttributeConfig{Enabled: false}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := loadResourceAttributesConfig(t, tt.name) + diff := cmp.Diff(tt.want, cfg, cmpopts.IgnoreUnexported(ResourceAttributeConfig{})) + require.Emptyf(t, diff, "Config mismatch (-expected +actual):\n%s", diff) + }) + } +} + +func loadResourceAttributesConfig(t *testing.T, name string) ResourceAttributesConfig { + cm, err := confmaptest.LoadConf(filepath.Join("testdata", "config.yaml")) + require.NoError(t, err) + sub, err := cm.Sub(name) + require.NoError(t, err) + sub, err = sub.Sub("resource_attributes") + require.NoError(t, err) + cfg := DefaultResourceAttributesConfig() + require.NoError(t, sub.Unmarshal(&cfg)) + return cfg +} diff --git a/receiver/systemdreceiver/internal/metadata/generated_metrics.go b/receiver/systemdreceiver/internal/metadata/generated_metrics.go new file mode 100644 index 0000000000000..3878891b284da --- /dev/null +++ b/receiver/systemdreceiver/internal/metadata/generated_metrics.go @@ -0,0 +1,291 @@ +// Code generated by mdatagen. DO NOT EDIT. + +package metadata + +import ( + "time" + + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/filter" + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/pdata/pmetric" + "go.opentelemetry.io/collector/receiver" +) + +// AttributeSystemdUnitActiveState specifies the value systemd.unit.active_state attribute. +type AttributeSystemdUnitActiveState int + +const ( + _ AttributeSystemdUnitActiveState = iota + AttributeSystemdUnitActiveStateActive + AttributeSystemdUnitActiveStateReloading + AttributeSystemdUnitActiveStateInactive + AttributeSystemdUnitActiveStateFailed + AttributeSystemdUnitActiveStateActivating + AttributeSystemdUnitActiveStateDeactivating + AttributeSystemdUnitActiveStateMaintenance + AttributeSystemdUnitActiveStateRefreshing +) + +// String returns the string representation of the AttributeSystemdUnitActiveState. +func (av AttributeSystemdUnitActiveState) String() string { + switch av { + case AttributeSystemdUnitActiveStateActive: + return "active" + case AttributeSystemdUnitActiveStateReloading: + return "reloading" + case AttributeSystemdUnitActiveStateInactive: + return "inactive" + case AttributeSystemdUnitActiveStateFailed: + return "failed" + case AttributeSystemdUnitActiveStateActivating: + return "activating" + case AttributeSystemdUnitActiveStateDeactivating: + return "deactivating" + case AttributeSystemdUnitActiveStateMaintenance: + return "maintenance" + case AttributeSystemdUnitActiveStateRefreshing: + return "refreshing" + } + return "" +} + +// MapAttributeSystemdUnitActiveState is a helper map of string to AttributeSystemdUnitActiveState attribute value. +var MapAttributeSystemdUnitActiveState = map[string]AttributeSystemdUnitActiveState{ + "active": AttributeSystemdUnitActiveStateActive, + "reloading": AttributeSystemdUnitActiveStateReloading, + "inactive": AttributeSystemdUnitActiveStateInactive, + "failed": AttributeSystemdUnitActiveStateFailed, + "activating": AttributeSystemdUnitActiveStateActivating, + "deactivating": AttributeSystemdUnitActiveStateDeactivating, + "maintenance": AttributeSystemdUnitActiveStateMaintenance, + "refreshing": AttributeSystemdUnitActiveStateRefreshing, +} + +var MetricsInfo = metricsInfo{ + SystemdUnitState: metricInfo{ + Name: "systemd.unit.state", + }, +} + +type metricsInfo struct { + SystemdUnitState metricInfo +} + +type metricInfo struct { + Name string +} + +type metricSystemdUnitState struct { + data pmetric.Metric // data buffer for generated metric. + config MetricConfig // metric config provided by user. + capacity int // max observed number of data points added to the metric. +} + +// init fills systemd.unit.state metric with initial data. +func (m *metricSystemdUnitState) init() { + m.data.SetName("systemd.unit.state") + m.data.SetDescription("1 if the check resulted in active_state matching the current state, otherwise 0.") + m.data.SetUnit("1") + m.data.SetEmptySum() + m.data.Sum().SetIsMonotonic(false) + m.data.Sum().SetAggregationTemporality(pmetric.AggregationTemporalityCumulative) + m.data.Sum().DataPoints().EnsureCapacity(m.capacity) +} + +func (m *metricSystemdUnitState) recordDataPoint(start pcommon.Timestamp, ts pcommon.Timestamp, val int64, systemdUnitActiveStateAttributeValue string) { + if !m.config.Enabled { + return + } + dp := m.data.Sum().DataPoints().AppendEmpty() + dp.SetStartTimestamp(start) + dp.SetTimestamp(ts) + dp.SetIntValue(val) + dp.Attributes().PutStr("systemd.unit.active_state", systemdUnitActiveStateAttributeValue) +} + +// updateCapacity saves max length of data point slices that will be used for the slice capacity. +func (m *metricSystemdUnitState) updateCapacity() { + if m.data.Sum().DataPoints().Len() > m.capacity { + m.capacity = m.data.Sum().DataPoints().Len() + } +} + +// emit appends recorded metric data to a metrics slice and prepares it for recording another set of data points. +func (m *metricSystemdUnitState) emit(metrics pmetric.MetricSlice) { + if m.config.Enabled && m.data.Sum().DataPoints().Len() > 0 { + m.updateCapacity() + m.data.MoveTo(metrics.AppendEmpty()) + m.init() + } +} + +func newMetricSystemdUnitState(cfg MetricConfig) metricSystemdUnitState { + m := metricSystemdUnitState{config: cfg} + if cfg.Enabled { + m.data = pmetric.NewMetric() + m.init() + } + return m +} + +// MetricsBuilder provides an interface for scrapers to report metrics while taking care of all the transformations +// required to produce metric representation defined in metadata and user config. +type MetricsBuilder struct { + config MetricsBuilderConfig // config of the metrics builder. + startTime pcommon.Timestamp // start time that will be applied to all recorded data points. + metricsCapacity int // maximum observed number of metrics per resource. + metricsBuffer pmetric.Metrics // accumulates metrics data before emitting. + buildInfo component.BuildInfo // contains version information. + resourceAttributeIncludeFilter map[string]filter.Filter + resourceAttributeExcludeFilter map[string]filter.Filter + metricSystemdUnitState metricSystemdUnitState +} + +// MetricBuilderOption applies changes to default metrics builder. +type MetricBuilderOption interface { + apply(*MetricsBuilder) +} + +type metricBuilderOptionFunc func(mb *MetricsBuilder) + +func (mbof metricBuilderOptionFunc) apply(mb *MetricsBuilder) { + mbof(mb) +} + +// WithStartTime sets startTime on the metrics builder. +func WithStartTime(startTime pcommon.Timestamp) MetricBuilderOption { + return metricBuilderOptionFunc(func(mb *MetricsBuilder) { + mb.startTime = startTime + }) +} +func NewMetricsBuilder(mbc MetricsBuilderConfig, settings receiver.Settings, options ...MetricBuilderOption) *MetricsBuilder { + mb := &MetricsBuilder{ + config: mbc, + startTime: pcommon.NewTimestampFromTime(time.Now()), + metricsBuffer: pmetric.NewMetrics(), + buildInfo: settings.BuildInfo, + metricSystemdUnitState: newMetricSystemdUnitState(mbc.Metrics.SystemdUnitState), + resourceAttributeIncludeFilter: make(map[string]filter.Filter), + resourceAttributeExcludeFilter: make(map[string]filter.Filter), + } + if mbc.ResourceAttributes.SystemdUnitName.MetricsInclude != nil { + mb.resourceAttributeIncludeFilter["systemd.unit.name"] = filter.CreateFilter(mbc.ResourceAttributes.SystemdUnitName.MetricsInclude) + } + if mbc.ResourceAttributes.SystemdUnitName.MetricsExclude != nil { + mb.resourceAttributeExcludeFilter["systemd.unit.name"] = filter.CreateFilter(mbc.ResourceAttributes.SystemdUnitName.MetricsExclude) + } + + for _, op := range options { + op.apply(mb) + } + return mb +} + +// NewResourceBuilder returns a new resource builder that should be used to build a resource associated with for the emitted metrics. +func (mb *MetricsBuilder) NewResourceBuilder() *ResourceBuilder { + return NewResourceBuilder(mb.config.ResourceAttributes) +} + +// updateCapacity updates max length of metrics and resource attributes that will be used for the slice capacity. +func (mb *MetricsBuilder) updateCapacity(rm pmetric.ResourceMetrics) { + if mb.metricsCapacity < rm.ScopeMetrics().At(0).Metrics().Len() { + mb.metricsCapacity = rm.ScopeMetrics().At(0).Metrics().Len() + } +} + +// ResourceMetricsOption applies changes to provided resource metrics. +type ResourceMetricsOption interface { + apply(pmetric.ResourceMetrics) +} + +type resourceMetricsOptionFunc func(pmetric.ResourceMetrics) + +func (rmof resourceMetricsOptionFunc) apply(rm pmetric.ResourceMetrics) { + rmof(rm) +} + +// WithResource sets the provided resource on the emitted ResourceMetrics. +// It's recommended to use ResourceBuilder to create the resource. +func WithResource(res pcommon.Resource) ResourceMetricsOption { + return resourceMetricsOptionFunc(func(rm pmetric.ResourceMetrics) { + res.CopyTo(rm.Resource()) + }) +} + +// WithStartTimeOverride overrides start time for all the resource metrics data points. +// This option should be only used if different start time has to be set on metrics coming from different resources. +func WithStartTimeOverride(start pcommon.Timestamp) ResourceMetricsOption { + return resourceMetricsOptionFunc(func(rm pmetric.ResourceMetrics) { + var dps pmetric.NumberDataPointSlice + metrics := rm.ScopeMetrics().At(0).Metrics() + for i := 0; i < metrics.Len(); i++ { + switch metrics.At(i).Type() { + case pmetric.MetricTypeGauge: + dps = metrics.At(i).Gauge().DataPoints() + case pmetric.MetricTypeSum: + dps = metrics.At(i).Sum().DataPoints() + } + for j := 0; j < dps.Len(); j++ { + dps.At(j).SetStartTimestamp(start) + } + } + }) +} + +// EmitForResource saves all the generated metrics under a new resource and updates the internal state to be ready for +// recording another set of data points as part of another resource. This function can be helpful when one scraper +// needs to emit metrics from several resources. Otherwise calling this function is not required, +// just `Emit` function can be called instead. +// Resource attributes should be provided as ResourceMetricsOption arguments. +func (mb *MetricsBuilder) EmitForResource(options ...ResourceMetricsOption) { + rm := pmetric.NewResourceMetrics() + ils := rm.ScopeMetrics().AppendEmpty() + ils.Scope().SetName(ScopeName) + ils.Scope().SetVersion(mb.buildInfo.Version) + ils.Metrics().EnsureCapacity(mb.metricsCapacity) + mb.metricSystemdUnitState.emit(ils.Metrics()) + + for _, op := range options { + op.apply(rm) + } + for attr, filter := range mb.resourceAttributeIncludeFilter { + if val, ok := rm.Resource().Attributes().Get(attr); ok && !filter.Matches(val.AsString()) { + return + } + } + for attr, filter := range mb.resourceAttributeExcludeFilter { + if val, ok := rm.Resource().Attributes().Get(attr); ok && filter.Matches(val.AsString()) { + return + } + } + + if ils.Metrics().Len() > 0 { + mb.updateCapacity(rm) + rm.MoveTo(mb.metricsBuffer.ResourceMetrics().AppendEmpty()) + } +} + +// Emit returns all the metrics accumulated by the metrics builder and updates the internal state to be ready for +// recording another set of metrics. This function will be responsible for applying all the transformations required to +// produce metric representation defined in metadata and user config, e.g. delta or cumulative. +func (mb *MetricsBuilder) Emit(options ...ResourceMetricsOption) pmetric.Metrics { + mb.EmitForResource(options...) + metrics := mb.metricsBuffer + mb.metricsBuffer = pmetric.NewMetrics() + return metrics +} + +// RecordSystemdUnitStateDataPoint adds a data point to systemd.unit.state metric. +func (mb *MetricsBuilder) RecordSystemdUnitStateDataPoint(ts pcommon.Timestamp, val int64, systemdUnitActiveStateAttributeValue AttributeSystemdUnitActiveState) { + mb.metricSystemdUnitState.recordDataPoint(mb.startTime, ts, val, systemdUnitActiveStateAttributeValue.String()) +} + +// Reset resets metrics builder to its initial state. It should be used when external metrics source is restarted, +// and metrics builder should update its startTime and reset it's internal state accordingly. +func (mb *MetricsBuilder) Reset(options ...MetricBuilderOption) { + mb.startTime = pcommon.NewTimestampFromTime(time.Now()) + for _, op := range options { + op.apply(mb) + } +} diff --git a/receiver/systemdreceiver/internal/metadata/generated_metrics_test.go b/receiver/systemdreceiver/internal/metadata/generated_metrics_test.go new file mode 100644 index 0000000000000..f7d3f66515b12 --- /dev/null +++ b/receiver/systemdreceiver/internal/metadata/generated_metrics_test.go @@ -0,0 +1,120 @@ +// Code generated by mdatagen. DO NOT EDIT. + +package metadata + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/pdata/pmetric" + "go.opentelemetry.io/collector/receiver/receivertest" + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" +) + +type testDataSet int + +const ( + testDataSetDefault testDataSet = iota + testDataSetAll + testDataSetNone +) + +func TestMetricsBuilder(t *testing.T) { + tests := []struct { + name string + metricsSet testDataSet + resAttrsSet testDataSet + expectEmpty bool + }{ + { + name: "default", + }, + { + name: "all_set", + metricsSet: testDataSetAll, + resAttrsSet: testDataSetAll, + }, + { + name: "none_set", + metricsSet: testDataSetNone, + resAttrsSet: testDataSetNone, + expectEmpty: true, + }, + { + name: "filter_set_include", + resAttrsSet: testDataSetAll, + }, + { + name: "filter_set_exclude", + resAttrsSet: testDataSetAll, + expectEmpty: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + start := pcommon.Timestamp(1_000_000_000) + ts := pcommon.Timestamp(1_000_001_000) + observedZapCore, observedLogs := observer.New(zap.WarnLevel) + settings := receivertest.NewNopSettings(receivertest.NopType) + settings.Logger = zap.New(observedZapCore) + mb := NewMetricsBuilder(loadMetricsBuilderConfig(t, tt.name), settings, WithStartTime(start)) + + expectedWarnings := 0 + + assert.Equal(t, expectedWarnings, observedLogs.Len()) + + defaultMetricsCount := 0 + allMetricsCount := 0 + + defaultMetricsCount++ + allMetricsCount++ + mb.RecordSystemdUnitStateDataPoint(ts, 1, AttributeSystemdUnitActiveStateActive) + + rb := mb.NewResourceBuilder() + rb.SetSystemdUnitName("systemd.unit.name-val") + res := rb.Emit() + metrics := mb.Emit(WithResource(res)) + + if tt.expectEmpty { + assert.Equal(t, 0, metrics.ResourceMetrics().Len()) + return + } + + assert.Equal(t, 1, metrics.ResourceMetrics().Len()) + rm := metrics.ResourceMetrics().At(0) + assert.Equal(t, res, rm.Resource()) + assert.Equal(t, 1, rm.ScopeMetrics().Len()) + ms := rm.ScopeMetrics().At(0).Metrics() + if tt.metricsSet == testDataSetDefault { + assert.Equal(t, defaultMetricsCount, ms.Len()) + } + if tt.metricsSet == testDataSetAll { + assert.Equal(t, allMetricsCount, ms.Len()) + } + validatedMetrics := make(map[string]bool) + for i := 0; i < ms.Len(); i++ { + switch ms.At(i).Name() { + case "systemd.unit.state": + assert.False(t, validatedMetrics["systemd.unit.state"], "Found a duplicate in the metrics slice: systemd.unit.state") + validatedMetrics["systemd.unit.state"] = true + assert.Equal(t, pmetric.MetricTypeSum, ms.At(i).Type()) + assert.Equal(t, 1, ms.At(i).Sum().DataPoints().Len()) + assert.Equal(t, "1 if the check resulted in active_state matching the current state, otherwise 0.", ms.At(i).Description()) + assert.Equal(t, "1", ms.At(i).Unit()) + assert.False(t, ms.At(i).Sum().IsMonotonic()) + assert.Equal(t, pmetric.AggregationTemporalityCumulative, ms.At(i).Sum().AggregationTemporality()) + dp := ms.At(i).Sum().DataPoints().At(0) + assert.Equal(t, start, dp.StartTimestamp()) + assert.Equal(t, ts, dp.Timestamp()) + assert.Equal(t, pmetric.NumberDataPointValueTypeInt, dp.ValueType()) + assert.Equal(t, int64(1), dp.IntValue()) + attrVal, ok := dp.Attributes().Get("systemd.unit.active_state") + assert.True(t, ok) + assert.Equal(t, "active", attrVal.Str()) + } + } + }) + } +} diff --git a/receiver/systemdreceiver/internal/metadata/generated_resource.go b/receiver/systemdreceiver/internal/metadata/generated_resource.go new file mode 100644 index 0000000000000..9268c7eb8b306 --- /dev/null +++ b/receiver/systemdreceiver/internal/metadata/generated_resource.go @@ -0,0 +1,36 @@ +// Code generated by mdatagen. DO NOT EDIT. + +package metadata + +import ( + "go.opentelemetry.io/collector/pdata/pcommon" +) + +// ResourceBuilder is a helper struct to build resources predefined in metadata.yaml. +// The ResourceBuilder is not thread-safe and must not to be used in multiple goroutines. +type ResourceBuilder struct { + config ResourceAttributesConfig + res pcommon.Resource +} + +// NewResourceBuilder creates a new ResourceBuilder. This method should be called on the start of the application. +func NewResourceBuilder(rac ResourceAttributesConfig) *ResourceBuilder { + return &ResourceBuilder{ + config: rac, + res: pcommon.NewResource(), + } +} + +// SetSystemdUnitName sets provided value as "systemd.unit.name" attribute. +func (rb *ResourceBuilder) SetSystemdUnitName(val string) { + if rb.config.SystemdUnitName.Enabled { + rb.res.Attributes().PutStr("systemd.unit.name", val) + } +} + +// Emit returns the built resource and resets the internal builder state. +func (rb *ResourceBuilder) Emit() pcommon.Resource { + r := rb.res + rb.res = pcommon.NewResource() + return r +} diff --git a/receiver/systemdreceiver/internal/metadata/generated_resource_test.go b/receiver/systemdreceiver/internal/metadata/generated_resource_test.go new file mode 100644 index 0000000000000..eb77215b214b6 --- /dev/null +++ b/receiver/systemdreceiver/internal/metadata/generated_resource_test.go @@ -0,0 +1,40 @@ +// Code generated by mdatagen. DO NOT EDIT. + +package metadata + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResourceBuilder(t *testing.T) { + for _, tt := range []string{"default", "all_set", "none_set"} { + t.Run(tt, func(t *testing.T) { + cfg := loadResourceAttributesConfig(t, tt) + rb := NewResourceBuilder(cfg) + rb.SetSystemdUnitName("systemd.unit.name-val") + + res := rb.Emit() + assert.Equal(t, 0, rb.Emit().Attributes().Len()) // Second call should return empty Resource + + switch tt { + case "default": + assert.Equal(t, 1, res.Attributes().Len()) + case "all_set": + assert.Equal(t, 1, res.Attributes().Len()) + case "none_set": + assert.Equal(t, 0, res.Attributes().Len()) + return + default: + assert.Failf(t, "unexpected test case: %s", tt) + } + + val, ok := res.Attributes().Get("systemd.unit.name") + assert.True(t, ok) + if ok { + assert.Equal(t, "systemd.unit.name-val", val.Str()) + } + }) + } +} diff --git a/receiver/systemdreceiver/internal/metadata/testdata/config.yaml b/receiver/systemdreceiver/internal/metadata/testdata/config.yaml new file mode 100644 index 0000000000000..66dfe42708f6a --- /dev/null +++ b/receiver/systemdreceiver/internal/metadata/testdata/config.yaml @@ -0,0 +1,27 @@ +default: +all_set: + metrics: + systemd.unit.state: + enabled: true + resource_attributes: + systemd.unit.name: + enabled: true +none_set: + metrics: + systemd.unit.state: + enabled: false + resource_attributes: + systemd.unit.name: + enabled: false +filter_set_include: + resource_attributes: + systemd.unit.name: + enabled: true + metrics_include: + - regexp: ".*" +filter_set_exclude: + resource_attributes: + systemd.unit.name: + enabled: true + metrics_exclude: + - strict: "systemd.unit.name-val" diff --git a/receiver/systemdreceiver/metadata.yaml b/receiver/systemdreceiver/metadata.yaml index c4fc79a00c908..9f35702983cf1 100644 --- a/receiver/systemdreceiver/metadata.yaml +++ b/receiver/systemdreceiver/metadata.yaml @@ -8,5 +8,37 @@ status: codeowners: active: [atoulme] emeritus: [Hemansh31] + unsupported_platforms: [darwin, windows] + +resource_attributes: + systemd.unit.name: + description: Name of the systemd unit + type: string + enabled: true + +attributes: + systemd.unit.active_state: + description: The active state of the unit (https://www.freedesktop.org/software/systemd/man/latest/systemd.html#Units) + type: string + enum: + - active + - reloading + - inactive + - failed + - activating + - deactivating + - maintenance + - refreshing + +metrics: + systemd.unit.state: + description: 1 if the check resulted in active_state matching the current state, otherwise 0. + enabled: true + sum: + value_type: int + aggregation_temporality: cumulative + monotonic: false + unit: "1" + attributes: [systemd.unit.active_state] tests: diff --git a/receiver/systemdreceiver/receiver.go b/receiver/systemdreceiver/receiver.go deleted file mode 100644 index 1c121a78b8795..0000000000000 --- a/receiver/systemdreceiver/receiver.go +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -package systemdreceiver // import "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/systemdreceiver" -import ( - "context" - - "go.opentelemetry.io/collector/component" -) - -type systemdReceiver struct{} - -func (systemdReceiver) Start(context.Context, component.Host) error { - return nil -} - -func (systemdReceiver) Shutdown(context.Context) error { - return nil -} diff --git a/receiver/systemdreceiver/scraper.go b/receiver/systemdreceiver/scraper.go new file mode 100644 index 0000000000000..a9d76bf2ae237 --- /dev/null +++ b/receiver/systemdreceiver/scraper.go @@ -0,0 +1,108 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package systemdreceiver // import "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/systemdreceiver" + +import ( + "context" + "time" + + "github.com/godbus/dbus/v5" + "go.opentelemetry.io/collector/component" + "go.opentelemetry.io/collector/pdata/pcommon" + "go.opentelemetry.io/collector/pdata/pmetric" + "go.opentelemetry.io/collector/receiver" + + "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/systemdreceiver/internal/metadata" +) + +// A connection to dbus. +// +// This has the same interface as [dbus.Conn], but allows us to mock it out for testing. +type dbusConnection interface { + Object(dest string, path dbus.ObjectPath) dbus.BusObject + Close() error +} + +type systemdScraper struct { + conn dbusConnection + cfg *Config + settings component.TelemetrySettings + mb *metadata.MetricsBuilder +} + +func (s *systemdScraper) start(ctx context.Context, _ component.Host) (err error) { + var conn *dbus.Conn + switch s.cfg.Scope { + case "system": + conn, err = dbus.ConnectSystemBus(dbus.WithContext(ctx)) + case "user": + conn, err = dbus.ConnectSessionBus(dbus.WithContext(ctx)) + default: + return errInvalidScope + } + + if err != nil { + return err + } + + s.conn = conn + + return err +} + +func (s *systemdScraper) shutdown(_ context.Context) (err error) { + if s.conn != nil { + err = s.conn.Close() + } + + return err +} + +type unitTuple struct { + Name string + Description string + LoadState string + ActiveState string + SubState string + Following string + Path dbus.ObjectPath + JobID uint32 + JobType string + JobPath dbus.ObjectPath +} + +func (s *systemdScraper) scrape(ctx context.Context) (pmetric.Metrics, error) { + now := pcommon.NewTimestampFromTime(time.Now()) + + var units []unitTuple + if err := s.conn.Object("org.freedesktop.systemd1", "/org/freedesktop/systemd1").CallWithContext(ctx, "org.freedesktop.systemd1.Manager.ListUnitsByPatterns", 0, []string{}, s.cfg.Units).Store(&units); err != nil { + return pmetric.NewMetrics(), err + } + + for i := range units { + unit := &units[i] + for stateName, state := range metadata.MapAttributeSystemdUnitActiveState { + if unit.ActiveState == stateName { + s.mb.RecordSystemdUnitStateDataPoint(now, int64(1), state) + } else { + s.mb.RecordSystemdUnitStateDataPoint(now, int64(0), state) + } + } + + resource := s.mb.NewResourceBuilder() + resource.SetSystemdUnitName(unit.Name) + s.mb.EmitForResource(metadata.WithResource(resource.Emit())) + } + + metrics := s.mb.Emit() + return metrics, nil +} + +func newScraper(conf *Config, settings receiver.Settings) *systemdScraper { + return &systemdScraper{ + cfg: conf, + settings: settings.TelemetrySettings, + mb: metadata.NewMetricsBuilder(conf.MetricsBuilderConfig, settings), + } +} diff --git a/receiver/systemdreceiver/scraper_test.go b/receiver/systemdreceiver/scraper_test.go new file mode 100644 index 0000000000000..8624bb6eaca30 --- /dev/null +++ b/receiver/systemdreceiver/scraper_test.go @@ -0,0 +1,180 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package systemdreceiver + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "testing" + + "github.com/godbus/dbus/v5" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/collector/receiver/receivertest" + + "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/golden" + "github.com/open-telemetry/opentelemetry-collector-contrib/pkg/pdatatest/pmetrictest" + "github.com/open-telemetry/opentelemetry-collector-contrib/receiver/systemdreceiver/internal/metadata" +) + +var errUnimplementedMethod = errors.New("unimplemented test method") + +type testDbusObject struct { + destination string + path dbus.ObjectPath + methods map[string][]any + properties map[string]dbus.Variant +} + +func (s *testDbusObject) Call(method string, _ dbus.Flags, _ ...any) *dbus.Call { + if value, exists := s.methods[method]; exists { + return &dbus.Call{Body: value} + } + return &dbus.Call{Err: fmt.Errorf("no such method %s", method)} +} + +func (s *testDbusObject) CallWithContext(_ context.Context, method string, flags dbus.Flags, args ...any) *dbus.Call { + return s.Call(method, flags, args) +} + +func (*testDbusObject) Go(_ string, _ dbus.Flags, _ chan *dbus.Call, _ ...any) *dbus.Call { + return &dbus.Call{Err: errUnimplementedMethod} +} + +func (*testDbusObject) GoWithContext(_ context.Context, _ string, _ dbus.Flags, _ chan *dbus.Call, _ ...any) *dbus.Call { + return &dbus.Call{Err: errUnimplementedMethod} +} + +func (*testDbusObject) AddMatchSignal(_, _ string, _ ...dbus.MatchOption) *dbus.Call { + return &dbus.Call{Err: errUnimplementedMethod} +} + +func (*testDbusObject) RemoveMatchSignal(_, _ string, _ ...dbus.MatchOption) *dbus.Call { + return &dbus.Call{Err: errUnimplementedMethod} +} + +func (s *testDbusObject) GetProperty(p string) (dbus.Variant, error) { + if value, exists := s.properties[p]; exists { + return value, nil + } + + return dbus.Variant{}, errors.New("no such property") +} + +func (*testDbusObject) StoreProperty(_ string, _ any) error { + return errUnimplementedMethod +} + +func (*testDbusObject) SetProperty(_ string, _ any) error { + return errUnimplementedMethod +} + +func (s *testDbusObject) Destination() string { + return s.destination +} + +func (s *testDbusObject) Path() dbus.ObjectPath { + return s.path +} + +type testDbusConnection struct { + isOpen bool + units []unitTuple +} + +func (s *testDbusConnection) Close() error { + if !s.isOpen { + return errors.New("connection already closed") + } + + s.isOpen = false + return nil +} + +func (s *testDbusConnection) Object(dest string, path dbus.ObjectPath) dbus.BusObject { + if dest == "org.freedesktop.systemd1" && path == "/org/freedesktop/systemd1" { + return &testDbusObject{ + destination: dest, + path: path, + methods: map[string][]any{ + "org.freedesktop.systemd1.Manager.ListUnitsByPatterns": {s.units}, + }, + } + } + + panic("unsupported object") +} + +func newTestScraper(conf *Config, units []unitTuple) *systemdScraper { + scraper := newScraper(conf, receivertest.NewNopSettings(metadata.Type)) + scraper.conn = &testDbusConnection{isOpen: true, units: units} + return scraper +} + +func TestScraperScrape(t *testing.T) { + testCases := []struct { + desc string + units []unitTuple + goldenName string + expectedErr error + }{ + { + desc: "Basic scrape", + units: []unitTuple{ + { + Name: "nginx.service", + Description: "A high performance web server and a reverse proxy server", + LoadState: "loaded", + ActiveState: "active", + SubState: "plugged", + Following: "", + Path: "/org/freedesktop/systemd1/unit/nginx_2eservice", + JobID: uint32(0), + JobType: "", + JobPath: "/", + }, + { + Name: "rsyslog.service", + Description: "Advanced key-value store", + LoadState: "loaded", + ActiveState: "failed", + SubState: "failed", + Following: "", + Path: "/org/freedesktop/systemd1/unit/rsyslog_2eservice", + JobID: uint32(0), + JobType: "", + JobPath: "/", + }, + }, + goldenName: "basic-scrape", + expectedErr: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + scraper := newTestScraper(createDefaultConfig().(*Config), tc.units) + + actualMetrics, err := scraper.scrape(t.Context()) + if tc.expectedErr == nil { + require.NoError(t, err) + } else { + require.EqualError(t, err, tc.expectedErr.Error()) + } + + goldenPath := filepath.Join("testdata", "expected_metrics", tc.goldenName+".yaml") + // golden.WriteMetrics(t, goldenPath, actualMetrics) + + expectedMetrics, err := golden.ReadMetrics(goldenPath) + require.NoError(t, err) + require.NoError(t, pmetrictest.CompareMetrics( + expectedMetrics, actualMetrics, + pmetrictest.IgnoreMetricDataPointsOrder(), + pmetrictest.IgnoreStartTimestamp(), + pmetrictest.IgnoreTimestamp(), + )) + }) + } +} diff --git a/receiver/systemdreceiver/testdata/expected_metrics/basic-scrape.yaml b/receiver/systemdreceiver/testdata/expected_metrics/basic-scrape.yaml new file mode 100644 index 0000000000000..0a91c7ee99576 --- /dev/null +++ b/receiver/systemdreceiver/testdata/expected_metrics/basic-scrape.yaml @@ -0,0 +1,145 @@ +resourceMetrics: + - resource: + attributes: + - key: systemd.unit.name + value: + stringValue: nginx.service + scopeMetrics: + - metrics: + - description: 1 if the check resulted in active_state matching the current state, otherwise 0. + name: systemd.unit.state + sum: + aggregationTemporality: 2 + dataPoints: + - asInt: "0" + attributes: + - key: systemd.unit.active_state + value: + stringValue: activating + startTimeUnixNano: "1000000" + timeUnixNano: "2000000" + - asInt: "1" + attributes: + - key: systemd.unit.active_state + value: + stringValue: active + startTimeUnixNano: "1000000" + timeUnixNano: "2000000" + - asInt: "0" + attributes: + - key: systemd.unit.active_state + value: + stringValue: deactivating + startTimeUnixNano: "1000000" + timeUnixNano: "2000000" + - asInt: "0" + attributes: + - key: systemd.unit.active_state + value: + stringValue: failed + startTimeUnixNano: "1000000" + timeUnixNano: "2000000" + - asInt: "0" + attributes: + - key: systemd.unit.active_state + value: + stringValue: inactive + startTimeUnixNano: "1000000" + timeUnixNano: "2000000" + - asInt: "0" + attributes: + - key: systemd.unit.active_state + value: + stringValue: maintenance + startTimeUnixNano: "1000000" + timeUnixNano: "2000000" + - asInt: "0" + attributes: + - key: systemd.unit.active_state + value: + stringValue: refreshing + startTimeUnixNano: "1000000" + timeUnixNano: "2000000" + - asInt: "0" + attributes: + - key: systemd.unit.active_state + value: + stringValue: reloading + startTimeUnixNano: "1000000" + timeUnixNano: "2000000" + unit: "1" + scope: + name: github.com/open-telemetry/opentelemetry-collector-contrib/receiver/systemdreceiver + version: latest + - resource: + attributes: + - key: systemd.unit.name + value: + stringValue: rsyslog.service + scopeMetrics: + - metrics: + - description: 1 if the check resulted in active_state matching the current state, otherwise 0. + name: systemd.unit.state + sum: + aggregationTemporality: 2 + dataPoints: + - asInt: "0" + attributes: + - key: systemd.unit.active_state + value: + stringValue: activating + startTimeUnixNano: "1000000" + timeUnixNano: "2000000" + - asInt: "0" + attributes: + - key: systemd.unit.active_state + value: + stringValue: active + startTimeUnixNano: "1000000" + timeUnixNano: "2000000" + - asInt: "0" + attributes: + - key: systemd.unit.active_state + value: + stringValue: deactivating + startTimeUnixNano: "1000000" + timeUnixNano: "2000000" + - asInt: "1" + attributes: + - key: systemd.unit.active_state + value: + stringValue: failed + startTimeUnixNano: "1000000" + timeUnixNano: "2000000" + - asInt: "0" + attributes: + - key: systemd.unit.active_state + value: + stringValue: inactive + startTimeUnixNano: "1000000" + timeUnixNano: "2000000" + - asInt: "0" + attributes: + - key: systemd.unit.active_state + value: + stringValue: maintenance + startTimeUnixNano: "1000000" + timeUnixNano: "2000000" + - asInt: "0" + attributes: + - key: systemd.unit.active_state + value: + stringValue: refreshing + startTimeUnixNano: "1000000" + timeUnixNano: "2000000" + - asInt: "0" + attributes: + - key: systemd.unit.active_state + value: + stringValue: reloading + startTimeUnixNano: "1000000" + timeUnixNano: "2000000" + unit: "1" + scope: + name: github.com/open-telemetry/opentelemetry-collector-contrib/receiver/systemdreceiver + version: latest