Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
128 changes: 121 additions & 7 deletions internal/output/opentelemetry/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"

"github.com/mstoykov/envconfig"
"github.com/sirupsen/logrus"
"go.k6.io/k6/errext"
"go.k6.io/k6/errext/exitcodes"
"go.k6.io/k6/internal/build"
Expand Down Expand Up @@ -50,7 +52,7 @@ type Config struct {
// ExportInterval configures the intervening time between metrics exports
ExportInterval types.NullDuration `json:"exportInterval" envconfig:"K6_OTEL_EXPORT_INTERVAL"`

// Headers in W3C Correlation-Context format without additional semi-colon delimited metadata (i.e. "k1=v1,k2=v2")
// Headers in W3C Correlation-Context format without additional semicolon-delimited metadata (i.e. "k1=v1,k2=v2")
Headers null.String `json:"headers" envconfig:"K6_OTEL_HEADERS"`

// TLSInsecureSkipVerify disables verification of the server's certificate chain
Expand Down Expand Up @@ -88,13 +90,27 @@ type Config struct {
// GetConsolidatedConfig combines the options' values from the different sources
// and returns the merged options. The Order of precedence used is documented
// in the k6 Documentation https://grafana.com/docs/k6/latest/using-k6/k6-options/how-to/#order-of-precedence.
func GetConsolidatedConfig(jsonRawConf json.RawMessage, env map[string]string) (Config, error) {
func GetConsolidatedConfig(
jsonRawConf json.RawMessage,
env map[string]string,
logger logrus.FieldLogger,
) (Config, error) {
// We start from the defaults.
cfg := newDefaultConfig()

// Then, we apply the OTLP exporter environment variables. So, these are used as the "defaults", if defined,
// while the k6-specific configuration options / environment variables remain with higher precedence, as follows.
cfg, err := applyOTELEnvVars(cfg, env)
if err != nil {
return cfg, fmt.Errorf("parse standard OTEL environment variables options failed: %w", err)
}

if jsonRawConf != nil {
jsonConf, err := parseJSON(jsonRawConf)
if err != nil {
return cfg, fmt.Errorf("parse JSON options failed: %w", err)
}
warnIfConfigMismatch(jsonConf, logger)
cfg = cfg.Apply(jsonConf)
}

Expand All @@ -103,11 +119,11 @@ func GetConsolidatedConfig(jsonRawConf json.RawMessage, env map[string]string) (
if err != nil {
return cfg, fmt.Errorf("parse environment variables options failed: %w", err)
}
warnIfConfigMismatch(envConf, logger)
cfg = cfg.Apply(envConf)
}

if err := cfg.Validate(); err != nil {
// TODO: check why k6's still exiting with 255
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this fixed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it seemed to me that this comment was outdated, that's why I removed it.

I checked it by running k6 with an invalid configuration (see below), and checking that the exit status is 104, which corresponds to the invalid configuration exit code.

❯ K6_OTEL_EXPORTER_PROTOCOL=error go run main.go run -o opentelemetry examples/docker-compose/opentelemetry/script.js

         /\      Grafana   /‾‾/                                                                                                                                                                                                                                 
    /\  /  \     |\  __   /  /                                                                                                                                                                                                                                  
   /  \/    \    | |/ /  /   ‾‾\                                                                                                                                                                                                                                
  /          \   |   (  |  (‾)  |                                                                                                                                                                                                                               
 / __________ \  |_|\_\  \_____/ 


Init      [--------------------------------------]
default   [--------------------------------------]
ERRO[0000] could not create the 'opentelemetry' output: error validating OpenTelemetry output config: unsupported exporter protocol "error", only "grpc" and "http/protobuf" are supported 
exit status 104

return cfg, errext.WithExitCodeIfNone(
fmt.Errorf("error validating OpenTelemetry output config: %w", err),
exitcodes.InvalidConfig,
Expand Down Expand Up @@ -328,14 +344,112 @@ func parseJSON(data json.RawMessage) (Config, error) {
func parseEnvs(env map[string]string) (Config, error) {
cfg := Config{}

if serviceName, ok := env["OTEL_SERVICE_NAME"]; ok {
cfg.ServiceName = null.StringFrom(serviceName)
}

err := envconfig.Process("K6_OTEL_", &cfg, func(key string) (string, bool) {
v, ok := env[key]
return v, ok
})

return cfg, err
}

// applyOTELEnvVars applies the OTLP exporter environment variables, if defined, to the supplied Config.
// As per OTLP Exporter configuration specification, signal-specific variables (e.g.,
// OTEL_EXPORTER_OTLP_METRICS_PROTOCOL) take precedence over general ones (e.g., OTEL_EXPORTER_OTLP_PROTOCOL).
func applyOTELEnvVars(defaultCfg Config, env map[string]string) (Config, error) {
stdCfg := Config{}

if serviceName, ok := env["OTEL_SERVICE_NAME"]; ok {
stdCfg.ServiceName = null.StringFrom(serviceName)
}

if exporterProtocol, ok := env["OTEL_EXPORTER_OTLP_METRICS_PROTOCOL"]; ok {
stdCfg.ExporterProtocol = null.StringFrom(exporterProtocol)
} else if exporterProtocol, ok := env["OTEL_EXPORTER_OTLP_PROTOCOL"]; ok {
stdCfg.ExporterProtocol = null.StringFrom(exporterProtocol)
}

if exportInterval, ok := env["OTEL_METRIC_EXPORT_INTERVAL"]; ok {
exportIntervalDuration, err := types.ParseExtendedDuration(exportInterval)
if err != nil {
return Config{}, err
}
stdCfg.ExportInterval = types.NullDurationFrom(exportIntervalDuration)
}

if exporterHeaders, ok := env["OTEL_EXPORTER_OTLP_METRICS_HEADERS"]; ok {
stdCfg.Headers = null.StringFrom(exporterHeaders)
} else if exporterHeaders, ok := env["OTEL_EXPORTER_OTLP_HEADERS"]; ok {
stdCfg.Headers = null.StringFrom(exporterHeaders)
}

if exporterCertificate, ok := env["OTEL_EXPORTER_OTLP_METRICS_CERTIFICATE"]; ok {
stdCfg.TLSCertificate = null.StringFrom(exporterCertificate)
} else if exporterCertificate, ok := env["OTEL_EXPORTER_OTLP_CERTIFICATE"]; ok {
stdCfg.TLSCertificate = null.StringFrom(exporterCertificate)
}

if exporterClientCertificate, ok := env["OTEL_EXPORTER_OTLP_METRICS_CLIENT_CERTIFICATE"]; ok {
stdCfg.TLSClientCertificate = null.StringFrom(exporterClientCertificate)
} else if exporterClientCertificate, ok := env["OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE"]; ok {
stdCfg.TLSClientCertificate = null.StringFrom(exporterClientCertificate)
}

if exporterClientKey, ok := env["OTEL_EXPORTER_OTLP_METRICS_CLIENT_KEY"]; ok {
stdCfg.TLSClientKey = null.StringFrom(exporterClientKey)
} else if exporterClientKey, ok := env["OTEL_EXPORTER_OTLP_CLIENT_KEY"]; ok {
stdCfg.TLSClientKey = null.StringFrom(exporterClientKey)
}

var exporterInsecureBoolVar string
if exporterInsecure, ok := env["OTEL_EXPORTER_OTLP_METRICS_INSECURE"]; ok {
exporterInsecureBoolVar = exporterInsecure
} else if exporterInsecure, ok := env["OTEL_EXPORTER_OTLP_INSECURE"]; ok {
exporterInsecureBoolVar = exporterInsecure
}

if exporterInsecureBoolVar != "" {
exporterInsecureBool, err := strconv.ParseBool(exporterInsecureBoolVar)
if err != nil {
return Config{}, err
}

stdCfg.GRPCExporterInsecure = null.BoolFrom(exporterInsecureBool)
stdCfg.HTTPExporterInsecure = null.BoolFrom(exporterInsecureBool)
}

if exporterEndpoint, ok := env["OTEL_EXPORTER_OTLP_METRICS_ENDPOINT"]; ok {
stdCfg.GRPCExporterEndpoint = null.StringFrom(exporterEndpoint)
stdCfg.HTTPExporterEndpoint = null.StringFrom(exporterEndpoint)
} else if exporterEndpoint, ok := env["OTEL_EXPORTER_OTLP_ENDPOINT"]; ok {
stdCfg.GRPCExporterEndpoint = null.StringFrom(exporterEndpoint)
stdCfg.HTTPExporterEndpoint = null.StringFrom(exporterEndpoint)
}
Comment on lines +415 to +456
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we have an issue to support these options for the HTTP exporter. I haven't tested it but from k6 doc:

K6_OTEL_HTTP_EXPORTER_ENDPOINT Configures the HTTP exporter endpoint. Must be host and port only, without scheme. Default is localhost:4318.

And from OpenTelemetry doc:

Additionally, the option MUST accept a URL with a scheme of either http or https. A scheme of https indicates a secure connection and takes precedence over the insecure configuration setting. A scheme of http indicates an insecure connection and takes precedence over the insecure configuration setting.

So first, users using the endpoint env variable like they are used will have errors when using http and https schemes. And if we fix that, we'll need to keep in mind that this should override the insecure setting.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch @AgnesToulet! Thanks! 🙌🏻 🙇🏻

How does 8633d53 look like? 🤔 I tried to apply what's stated in the aforementioned docs.


return defaultCfg.Apply(stdCfg), nil
}

// warnIfConfigMismatch writes a warning log message in case of discrepancies between the configured
// Config.ExporterProtocol and other configuration attributes. E.g., in case the user explicitly configured
// Config.HTTPExporterEndpoint while the chosen protocol is `grpc`.
func warnIfConfigMismatch(cfg Config, logger logrus.FieldLogger) {
exporterProtocol := cfg.ExporterProtocol.String
switch {
case exporterProtocol == grpcExporterProtocol && anyHTTPOptionSet(cfg):
logger.Warn("Configuration mismatch detected: the gRPC exporter type is set, but also some HTTP " +
"configuration options")
case exporterProtocol == httpExporterProtocol && anyGRPCOptionSet(cfg):
logger.Warn("Configuration mismatch detected: the HTTP exporter type is set, but also some gRPC " +
"configuration options")
}
}

func anyHTTPOptionSet(cfg Config) bool {
return cfg.HTTPExporterInsecure.Valid ||
cfg.HTTPExporterEndpoint.Valid ||
cfg.HTTPExporterURLPath.Valid
}

func anyGRPCOptionSet(cfg Config) bool {
return cfg.GRPCExporterInsecure.Valid ||
cfg.GRPCExporterEndpoint.Valid
}
126 changes: 116 additions & 10 deletions internal/output/opentelemetry/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package opentelemetry

import (
"encoding/json"
"io"
"testing"
"time"

"github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
"go.k6.io/k6/internal/build"
"go.k6.io/k6/lib/types"
Expand Down Expand Up @@ -37,6 +39,37 @@ func TestConfig(t *testing.T) {
},
},

"OTLP exporter env vars overwrite defaults": {
env: map[string]string{
"OTEL_SERVICE_NAME": "k6-test",
"OTEL_EXPORTER_OTLP_PROTOCOL": httpExporterProtocol,
"OTEL_METRIC_EXPORT_INTERVAL": "1m",
"OTEL_EXPORTER_OTLP_HEADERS": "k1=v1,k2=v2",
"OTEL_EXPORTER_OTLP_CERTIFICATE": "fake-certificate",
"OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE": "fake-client-certificate",
"OTEL_EXPORTER_OTLP_CLIENT_KEY": "fake-client-key",
"OTEL_EXPORTER_OTLP_INSECURE": "true",
"OTEL_EXPORTER_OTLP_ENDPOINT": "localhost:4317",
},
expectedConfig: Config{
ServiceName: null.StringFrom("k6-test"),
ServiceVersion: null.NewString(build.Version, false),
ExporterProtocol: null.StringFrom(httpExporterProtocol),
HTTPExporterInsecure: null.BoolFrom(true),
HTTPExporterEndpoint: null.StringFrom("localhost:4317"),
HTTPExporterURLPath: null.NewString("/v1/metrics", false),
GRPCExporterInsecure: null.BoolFrom(true),
GRPCExporterEndpoint: null.StringFrom("localhost:4317"),
ExportInterval: types.NullDurationFrom(1 * time.Minute),
FlushInterval: types.NewNullDuration(1*time.Second, false),
SingleCounterForRate: null.NewBool(true, false),
Headers: null.StringFrom("k1=v1,k2=v2"),
TLSCertificate: null.StringFrom("fake-certificate"),
TLSClientCertificate: null.StringFrom("fake-client-certificate"),
TLSClientKey: null.StringFrom("fake-client-key"),
},
},

"environment success merge": {
env: map[string]string{"K6_OTEL_GRPC_EXPORTER_ENDPOINT": "else", "K6_OTEL_EXPORT_INTERVAL": "4ms"},
expectedConfig: Config{
Expand Down Expand Up @@ -95,22 +128,45 @@ func TestConfig(t *testing.T) {
},
},

"OTEL environment variables": {
"OTLP exporter env vars overwritten by k6 env vars": {
env: map[string]string{
"OTEL_SERVICE_NAME": "otel-service",
"OTEL_SERVICE_NAME": "k6-test",
"K6_OTEL_SERVICE_NAME": "foo",
"OTEL_EXPORTER_OTLP_PROTOCOL": httpExporterProtocol,
"K6_OTEL_EXPORTER_PROTOCOL": grpcExporterProtocol,
"OTEL_METRIC_EXPORT_INTERVAL": "1m",
"K6_OTEL_EXPORT_INTERVAL": "4ms",
"OTEL_EXPORTER_OTLP_HEADERS": "k1=v1,k2=v2",
"K6_OTEL_HEADERS": "key1=value1,key2=value2",
"OTEL_EXPORTER_OTLP_CERTIFICATE": "fake-certificate",
"K6_OTEL_TLS_CERTIFICATE": "cert_path",
"OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE": "fake-client-certificate",
"K6_OTEL_TLS_CLIENT_CERTIFICATE": "client_cert_path",
"OTEL_EXPORTER_OTLP_CLIENT_KEY": "fake-client-key",
"K6_OTEL_TLS_CLIENT_KEY": "client_key_path",
"OTEL_EXPORTER_OTLP_INSECURE": "true",
"K6_OTEL_HTTP_EXPORTER_INSECURE": "false",
"K6_OTEL_GRPC_EXPORTER_INSECURE": "false",
"OTEL_EXPORTER_OTLP_ENDPOINT": "localhost:4317",
"K6_OTEL_HTTP_EXPORTER_ENDPOINT": "localhost:4318",
"K6_OTEL_GRPC_EXPORTER_ENDPOINT": "localhost:4318",
},
expectedConfig: Config{
ServiceName: null.NewString("otel-service", true),
ServiceName: null.StringFrom("foo"),
ServiceVersion: null.NewString(build.Version, false),
ExporterProtocol: null.NewString(grpcExporterProtocol, false),
HTTPExporterInsecure: null.NewBool(false, false),
HTTPExporterEndpoint: null.NewString("localhost:4318", false),
ExporterProtocol: null.StringFrom(grpcExporterProtocol),
HTTPExporterInsecure: null.BoolFrom(false),
HTTPExporterEndpoint: null.StringFrom("localhost:4318"),
HTTPExporterURLPath: null.NewString("/v1/metrics", false),
GRPCExporterInsecure: null.NewBool(false, false),
GRPCExporterEndpoint: null.NewString("localhost:4317", false),
ExportInterval: types.NewNullDuration(10*time.Second, false),
GRPCExporterInsecure: null.BoolFrom(false),
GRPCExporterEndpoint: null.StringFrom("localhost:4318"),
ExportInterval: types.NullDurationFrom(4 * time.Millisecond),
FlushInterval: types.NewNullDuration(1*time.Second, false),
SingleCounterForRate: null.NewBool(true, false),
Headers: null.StringFrom("key1=value1,key2=value2"),
TLSCertificate: null.StringFrom("cert_path"),
TLSClientCertificate: null.StringFrom("client_cert_path"),
TLSClientKey: null.StringFrom("client_key_path"),
},
},

Expand Down Expand Up @@ -157,6 +213,52 @@ func TestConfig(t *testing.T) {
},
},

"OTLP exporter env vars overwritten by JSON config": {
jsonRaw: json.RawMessage(
`{` +
`"serviceName":"foo",` +
`"exporterProtocol":"grpc",` +
`"exportInterval":"15ms",` +
`"httpExporterInsecure":false,` +
`"httpExporterEndpoint":"localhost:4318",` +
`"grpcExporterInsecure":false,` +
`"grpcExporterEndpoint":"localhost:4318",` +
`"tlsCertificate":"cert_path",` +
`"tlsClientCertificate":"client_cert_path",` +
`"tlsClientKey":"client_key_path",` +
`"headers":"key1=value1,key2=value2"` +
`}`,
),
env: map[string]string{
"OTEL_SERVICE_NAME": "k6-test",
"OTEL_EXPORTER_OTLP_PROTOCOL": httpExporterProtocol,
"OTEL_METRIC_EXPORT_INTERVAL": "1m",
"OTEL_EXPORTER_OTLP_HEADERS": "k1=v1,k2=v2",
"OTEL_EXPORTER_OTLP_CERTIFICATE": "fake-certificate",
"OTEL_EXPORTER_OTLP_CLIENT_CERTIFICATE": "fake-client-certificate",
"OTEL_EXPORTER_OTLP_CLIENT_KEY": "fake-client-key",
"OTEL_EXPORTER_OTLP_INSECURE": "true",
"OTEL_EXPORTER_OTLP_ENDPOINT": "localhost:4317",
},
expectedConfig: Config{
ServiceName: null.StringFrom("foo"),
ServiceVersion: null.NewString(build.Version, false),
ExporterProtocol: null.StringFrom(grpcExporterProtocol),
HTTPExporterInsecure: null.BoolFrom(false),
HTTPExporterEndpoint: null.StringFrom("localhost:4318"),
HTTPExporterURLPath: null.NewString("/v1/metrics", false),
GRPCExporterInsecure: null.BoolFrom(false),
GRPCExporterEndpoint: null.StringFrom("localhost:4318"),
ExportInterval: types.NullDurationFrom(15 * time.Millisecond),
FlushInterval: types.NewNullDuration(1*time.Second, false),
SingleCounterForRate: null.NewBool(true, false),
Headers: null.StringFrom("key1=value1,key2=value2"),
TLSCertificate: null.StringFrom("cert_path"),
TLSClientCertificate: null.StringFrom("client_cert_path"),
TLSClientKey: null.StringFrom("client_key_path"),
},
},

"JSON success merge": {
jsonRaw: json.RawMessage(`{"exporterType":"http","httpExporterEndpoint":"localhost:5566","httpExporterURLPath":"/lorem/ipsum","exportInterval":"15ms"}`),
expectedConfig: Config{
Expand Down Expand Up @@ -213,7 +315,11 @@ func TestConfig(t *testing.T) {
for name, testCase := range testCases {
t.Run(name, func(t *testing.T) {
t.Parallel()
config, err := GetConsolidatedConfig(testCase.jsonRaw, testCase.env)

logger := logrus.New()
logger.SetOutput(io.Discard)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we test warnIfConfigMismatch here instead of discarding the logs?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea! I have added a new test in 4d3a1c2. I preferred this, because TestConfig is way more generic, so we avoid having such specific assertion within a very generic test function.


config, err := GetConsolidatedConfig(testCase.jsonRaw, testCase.env, logger)
if testCase.err != "" {
require.Error(t, err)
require.Contains(t, err.Error(), testCase.err)
Expand Down
2 changes: 1 addition & 1 deletion internal/output/opentelemetry/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ var _ output.WithStopWithTestError = new(Output)

// New creates an instance of the collector
func New(p output.Params) (*Output, error) {
conf, err := GetConsolidatedConfig(p.JSONConfig, p.Environment)
conf, err := GetConsolidatedConfig(p.JSONConfig, p.Environment, p.Logger)
if err != nil {
return nil, err
}
Expand Down
Loading