diff --git a/CHANGELOG.md b/CHANGELOG.md index 659d887bb3d..c3eb300b28c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Added +- The `WithHostID` option to `go.opentelemetry.io/otel/sdk/resource`. (#3812) - The `WithoutTimestamps` option to `go.opentelemetry.io/otel/exporters/stdout/stdoutmetric` to sets all timestamps to zero. (#3828) - The new `Exemplar` type is added to `go.opentelemetry.io/otel/sdk/metric/metricdata`. Both the `DataPoint` and `HistogramDataPoint` types from that package have a new field of `Exemplars` containing the sampled exemplars for their timeseries. (#3849) diff --git a/sdk/resource/config.go b/sdk/resource/config.go index f9a2a299907..f263919f6ec 100644 --- a/sdk/resource/config.go +++ b/sdk/resource/config.go @@ -71,6 +71,11 @@ func WithHost() Option { return WithDetectors(host{}) } +// WithHostID adds host ID information to the configured resource. +func WithHostID() Option { + return WithDetectors(hostIDDetector{}) +} + // WithTelemetrySDK adds TelemetrySDK version info to the configured resource. func WithTelemetrySDK() Option { return WithDetectors(telemetrySDK{}) diff --git a/sdk/resource/host_id.go b/sdk/resource/host_id.go new file mode 100644 index 00000000000..32a616d0879 --- /dev/null +++ b/sdk/resource/host_id.go @@ -0,0 +1,142 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package resource // import "go.opentelemetry.io/otel/sdk/resource" + +import ( + "context" + "errors" + "os" + "os/exec" + "strings" + + semconv "go.opentelemetry.io/otel/semconv/v1.17.0" +) + +type hostIDProvider func() (string, error) + +var defaultHostIDProvider hostIDProvider = platformHostIDReader.read + +var hostID = defaultHostIDProvider + +type hostIDReader interface { + read() (string, error) +} + +type fileReader func(string) (string, error) + +type commandExecutor func(string, ...string) (string, error) + +func readFile(filename string) (string, error) { + b, err := os.ReadFile(filename) + if err != nil { + return "", nil + } + + return string(b), nil +} + +// nolint: unused // This is used by the hostReaderBSD, gated by build tags. +func execCommand(name string, arg ...string) (string, error) { + cmd := exec.Command(name, arg...) + b, err := cmd.Output() + if err != nil { + return "", err + } + + return string(b), nil +} + +// hostIDReaderBSD implements hostIDReader. +type hostIDReaderBSD struct { + execCommand commandExecutor + readFile fileReader +} + +// read attempts to read the machine-id from /etc/hostid. If not found it will +// execute `kenv -q smbios.system.uuid`. If neither location yields an id an +// error will be returned. +func (r *hostIDReaderBSD) read() (string, error) { + if result, err := r.readFile("/etc/hostid"); err == nil { + return strings.TrimSpace(result), nil + } + + if result, err := r.execCommand("kenv", "-q", "smbios.system.uuid"); err == nil { + return strings.TrimSpace(result), nil + } + + return "", errors.New("host id not found in: /etc/hostid or kenv") +} + +// hostIDReaderDarwin implements hostIDReader. +type hostIDReaderDarwin struct { + execCommand commandExecutor +} + +// read executes `ioreg -rd1 -c "IOPlatformExpertDevice"` and parses host id +// from the IOPlatformUUID line. If the command fails or the uuid cannot be +// parsed an error will be returned. +func (r *hostIDReaderDarwin) read() (string, error) { + result, err := r.execCommand("ioreg", "-rd1", "-c", "IOPlatformExpertDevice") + if err != nil { + return "", err + } + + lines := strings.Split(result, "\n") + for _, line := range lines { + if strings.Contains(line, "IOPlatformUUID") { + parts := strings.Split(line, " = ") + if len(parts) == 2 { + return strings.Trim(parts[1], "\""), nil + } + break + } + } + + return "", errors.New("could not parse IOPlatformUUID") +} + +type hostIDReaderLinux struct { + readFile fileReader +} + +// read attempts to read the machine-id from /etc/machine-id followed by +// /var/lib/dbus/machine-id. If neither location yields an ID an error will +// be returned. +func (r *hostIDReaderLinux) read() (string, error) { + if result, err := r.readFile("/etc/machine-id"); err == nil { + return strings.TrimSpace(result), nil + } + + if result, err := r.readFile("/var/lib/dbus/machine-id"); err == nil { + return strings.TrimSpace(result), nil + } + + return "", errors.New("host id not found in: /etc/machine-id or /var/lib/dbus/machine-id") +} + +type hostIDDetector struct{} + +// Detect returns a *Resource containing the platform specific host id. +func (hostIDDetector) Detect(ctx context.Context) (*Resource, error) { + hostID, err := hostID() + if err != nil { + return nil, err + } + + return NewWithAttributes( + semconv.SchemaURL, + semconv.HostID(hostID), + ), nil +} diff --git a/sdk/resource/host_id_bsd.go b/sdk/resource/host_id_bsd.go new file mode 100644 index 00000000000..0037b65da45 --- /dev/null +++ b/sdk/resource/host_id_bsd.go @@ -0,0 +1,28 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build dragonfly || freebsd || netbsd || openbsd || solaris +// +build dragonfly freebsd netbsd openbsd solaris + +package resource // import "go.opentelemetry.io/otel/sdk/resource" + +import ( + "errors" + "strings" +) + +var platformHostIDReader hostIDReader = &hostIDReaderBSD{ + execCommand: execCommand, + readFile: readFile, +} diff --git a/sdk/resource/host_id_darwin.go b/sdk/resource/host_id_darwin.go new file mode 100644 index 00000000000..ba41409b23c --- /dev/null +++ b/sdk/resource/host_id_darwin.go @@ -0,0 +1,19 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package resource // import "go.opentelemetry.io/otel/sdk/resource" + +var platformHostIDReader hostIDReader = &hostIDReaderDarwin{ + execCommand: execCommand, +} diff --git a/sdk/resource/host_id_export_test.go b/sdk/resource/host_id_export_test.go new file mode 100644 index 00000000000..e74d9a35d8b --- /dev/null +++ b/sdk/resource/host_id_export_test.go @@ -0,0 +1,37 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package resource_test + +import ( + "github.com/stretchr/testify/assert" + + "go.opentelemetry.io/otel/sdk/resource" +) + +func mockHostIDProvider() { + resource.SetHostIDProvider( + func() (string, error) { return "f2c668b579780554f70f72a063dc0864", nil }, + ) +} + +func mockHostIDProviderWithError() { + resource.SetHostIDProvider( + func() (string, error) { return "", assert.AnError }, + ) +} + +func restoreHostIDProvider() { + resource.SetDefaultHostIDProvider() +} diff --git a/sdk/resource/host_id_linux.go b/sdk/resource/host_id_linux.go new file mode 100644 index 00000000000..410579b8fc9 --- /dev/null +++ b/sdk/resource/host_id_linux.go @@ -0,0 +1,22 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build linux +// +build linux + +package resource // import "go.opentelemetry.io/otel/sdk/resource" + +var platformHostIDReader hostIDReader = &hostIDReaderLinux{ + readFile: readFile, +} diff --git a/sdk/resource/host_id_test.go b/sdk/resource/host_id_test.go new file mode 100644 index 00000000000..b20c2663714 --- /dev/null +++ b/sdk/resource/host_id_test.go @@ -0,0 +1,222 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package resource + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +var ( + expectedHostID = "f2c668b579780554f70f72a063dc0864" + + readFileNoError = func(filename string) (string, error) { + return expectedHostID + "\n", nil + } + + readFileError = func(filename string) (string, error) { + return "", errors.New("not found") + } + + execCommandNoError = func(string, ...string) (string, error) { + return expectedHostID + "\n", nil + } + + execCommandError = func(string, ...string) (string, error) { + return "", errors.New("not found") + } +) + +func SetDefaultHostIDProvider() { + SetHostIDProvider(defaultHostIDProvider) +} + +func SetHostIDProvider(hostIDProvider hostIDProvider) { + hostID = hostIDProvider +} + +func TestHostIDReaderBSD(t *testing.T) { + tt := []struct { + name string + fileReader fileReader + commandExecutor commandExecutor + expectedHostID string + expectError bool + }{ + { + name: "hostIDReaderBSD valid primary", + fileReader: readFileNoError, + commandExecutor: execCommandError, + expectedHostID: expectedHostID, + expectError: false, + }, + { + name: "hostIDReaderBSD invalid primary", + fileReader: readFileError, + commandExecutor: execCommandNoError, + expectedHostID: expectedHostID, + expectError: false, + }, + { + name: "hostIDReaderBSD invalid primary and secondary", + fileReader: readFileError, + commandExecutor: execCommandError, + expectedHostID: "", + expectError: true, + }, + } + + for _, tc := range tt { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + reader := hostIDReaderBSD{ + readFile: tc.fileReader, + execCommand: tc.commandExecutor, + } + hostID, err := reader.read() + require.Equal(t, tc.expectError, err != nil) + require.Equal(t, tc.expectedHostID, hostID) + }) + } +} + +func TestHostIDReaderLinux(t *testing.T) { + readFilePrimaryError := func(filename string) (string, error) { + if filename == "/var/lib/dbus/machine-id" { + return readFileNoError(filename) + } + return readFileError(filename) + } + + tt := []struct { + name string + fileReader fileReader + expectedHostID string + expectError bool + }{ + { + name: "hostIDReaderLinux valid primary", + fileReader: readFileNoError, + expectedHostID: expectedHostID, + expectError: false, + }, + { + name: "hostIDReaderLinux invalid primary", + fileReader: readFilePrimaryError, + expectedHostID: expectedHostID, + expectError: false, + }, + { + name: "hostIDReaderLinux invalid primary and secondary", + fileReader: readFileError, + expectedHostID: "", + expectError: true, + }, + } + + for _, tc := range tt { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + reader := hostIDReaderLinux{ + readFile: tc.fileReader, + } + hostID, err := reader.read() + require.Equal(t, tc.expectError, err != nil) + require.Equal(t, tc.expectedHostID, hostID) + }) + } +} + +func TestHostIDReaderDarwin(t *testing.T) { + validOutput := `+-o J316sAP +{ + "IOPolledInterface" = "AppleARMWatchdogTimerHibernateHandler is not serializable" + "#address-cells" = <02000000> + "AAPL,phandle" = <01000000> + "serial-number" = <94e1c79ec04cd3f153f600000000000000000000000000000000000000000000> + "IOBusyInterest" = "IOCommand is not serializable" + "target-type" = <"J316s"> + "platform-name" = <7436303030000000000000000000000000000000000000000000000000000000> + "secure-root-prefix" = <"md"> + "name" = <"device-tree"> + "region-info" = <4c4c2f4100000000000000000000000000000000000000000000000000000000> + "manufacturer" = <"Apple Inc."> + "compatible" = <"J316sAP","MacBookPro18,1","AppleARM"> + "config-number" = <00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000> + "IOPlatformSerialNumber" = "HDWLIF2LM7" + "regulatory-model-number" = <4132343835000000000000000000000000000000000000000000000000000000> + "time-stamp" = <"Fri Aug 5 20:25:38 PDT 2022"> + "clock-frequency" = <00366e01> + "model" = <"MacBookPro18,1"> + "mlb-serial-number" = <5c92d268d6cd789e475ffafc0d363fc950000000000000000000000000000000> + "model-number" = <5a31345930303136430000000000000000000000000000000000000000000000> + "IONWInterrupts" = "IONWInterrupts" + "model-config" = <"ICT;MoPED=0x03D053A605C84ED11C455A18D6C643140B41A239"> + "device_type" = <"bootrom"> + "#size-cells" = <02000000> + "IOPlatformUUID" = "81895B8D-9EF9-4EBB-B5DE-B00069CF53F0" +} +` + execCommandValid := func(string, ...string) (string, error) { + return validOutput, nil + } + + execCommandInvalid := func(string, ...string) (string, error) { + return "wasn't expecting this", nil + } + + tt := []struct { + name string + fileReader fileReader + commandExecutor commandExecutor + expectedHostID string + expectError bool + }{ + { + name: "hostIDReaderDarwin valid output", + commandExecutor: execCommandValid, + expectedHostID: "81895B8D-9EF9-4EBB-B5DE-B00069CF53F0", + expectError: false, + }, + { + name: "hostIDReaderDarwin invalid output", + commandExecutor: execCommandInvalid, + expectedHostID: "", + expectError: true, + }, + { + name: "hostIDReaderDarwin error", + commandExecutor: execCommandError, + expectedHostID: "", + expectError: true, + }, + } + + for _, tc := range tt { + tc := tc + t.Run(tc.name, func(t *testing.T) { + reader := hostIDReaderDarwin{ + execCommand: tc.commandExecutor, + } + hostID, err := reader.read() + require.Equal(t, tc.expectError, err != nil) + require.Equal(t, tc.expectedHostID, hostID) + }) + } +} diff --git a/sdk/resource/host_id_unsupported.go b/sdk/resource/host_id_unsupported.go new file mode 100644 index 00000000000..89df9d6882e --- /dev/null +++ b/sdk/resource/host_id_unsupported.go @@ -0,0 +1,36 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// +build !darwin +// +build !dragonfly +// +build !freebsd +// +build !linux +// +build !netbsd +// +build !openbsd +// +build !solaris +// +build !windows + +package resource // import "go.opentelemetry.io/otel/sdk/resource" + +// hostIDReaderUnsupported is a placeholder implementation for operating systems +// for which this project currently doesn't support host.id +// attribute detection. See build tags declaration early on this file +// for a list of unsupported OSes. +type hostIDReaderUnsupported struct{} + +func (*hostIDReaderUnsupported) read() (string, error) { + return "", nil +} + +var platformHostIDReader hostIDReader = &hostIDReaderUnsupported{} diff --git a/sdk/resource/host_id_windows.go b/sdk/resource/host_id_windows.go new file mode 100644 index 00000000000..5b431c6ee6e --- /dev/null +++ b/sdk/resource/host_id_windows.go @@ -0,0 +1,48 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows +// +build windows + +package resource // import "go.opentelemetry.io/otel/sdk/resource" + +import ( + "golang.org/x/sys/windows/registry" +) + +// implements hostIDReader +type hostIDReaderWindows struct{} + +// read reads MachineGuid from the windows registry key: +// SOFTWARE\Microsoft\Cryptography +func (*hostIDReaderWindows) read() (string, error) { + k, err := registry.OpenKey( + registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Cryptography`, + registry.QUERY_VALUE|registry.WOW64_64KEY, + ) + + if err != nil { + return "", err + } + defer k.Close() + + guid, _, err := k.GetStringValue("MachineGuid") + if err != nil { + return "", err + } + + return guid, nil +} + +var platformHostIDReader hostIDReader = &hostIDReaderWindows{} diff --git a/sdk/resource/host_id_windows_test.go b/sdk/resource/host_id_windows_test.go new file mode 100644 index 00000000000..656b005c6af --- /dev/null +++ b/sdk/resource/host_id_windows_test.go @@ -0,0 +1,32 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build windows +// +build windows + +package resource // import "go.opentelemetry.io/otel/sdk/resource" + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestReader(t *testing.T) { + reader := &hostIDReaderWindows{} + result, err := reader.read() + + require.NoError(t, err) + require.NotEmpty(t, result) +} diff --git a/sdk/resource/resource_test.go b/sdk/resource/resource_test.go index 29803b143d0..4cf346fd172 100644 --- a/sdk/resource/resource_test.go +++ b/sdk/resource/resource_test.go @@ -471,6 +471,36 @@ func TestNewWrapedError(t *testing.T) { assert.NotErrorIs(t, err, errors.New("false positive error")) } +func TestWithHostID(t *testing.T) { + mockHostIDProvider() + t.Cleanup(restoreHostIDProvider) + + ctx := context.Background() + + res, err := resource.New(ctx, + resource.WithHostID(), + ) + + require.NoError(t, err) + require.EqualValues(t, map[string]string{ + "host.id": "f2c668b579780554f70f72a063dc0864", + }, toMap(res)) +} + +func TestWithHostIDError(t *testing.T) { + mockHostIDProviderWithError() + t.Cleanup(restoreHostIDProvider) + + ctx := context.Background() + + res, err := resource.New(ctx, + resource.WithHostID(), + ) + + assert.ErrorIs(t, err, assert.AnError) + require.EqualValues(t, map[string]string{}, toMap(res)) +} + func TestWithOSType(t *testing.T) { mockRuntimeProviders() t.Cleanup(restoreAttributesProviders)