Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
208 changes: 201 additions & 7 deletions internal/output/opentelemetry/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import (
"encoding/json"
"errors"
"fmt"
"net/url"
"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 +53,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 +91,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 +120,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 +345,191 @@ 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).
//
//nolint:gocognit,cyclop,funlen
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)
}

isHTTP := stdCfg.ExporterProtocol.Valid && stdCfg.ExporterProtocol.String == httpExporterProtocol

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
}

if isHTTP {
stdCfg.HTTPExporterInsecure = null.BoolFrom(exporterInsecureBool)
} else {
stdCfg.GRPCExporterInsecure = null.BoolFrom(exporterInsecureBool)
}
}

var (
exporterEndpointVar string
exporterEndpointString string
)
if exporterEndpointVal, ok := env["OTEL_EXPORTER_OTLP_METRICS_ENDPOINT"]; ok {
exporterEndpointVar = "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT"
exporterEndpointString = exporterEndpointVal
} else if exporterEndpointVal, ok := env["OTEL_EXPORTER_OTLP_ENDPOINT"]; ok {
exporterEndpointVar = "OTEL_EXPORTER_OTLP_ENDPOINT"
exporterEndpointString = exporterEndpointVal
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 wonder from this paragraph if we should add /v1/metrics to this value.

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.

Makes sense, I'll try to make sure this gets appended if we populate the endpoint from "standard" OTLP variables 👍🏻

}

if exporterEndpointString != "" {
exporterEndpoint, insecure, err := parseOTELEndpoint(exporterEndpointString, isHTTP)
if err != nil {
return Config{}, fmt.Errorf("failed to parse %s: %w", exporterEndpointVar, err)
}

if isHTTP {
stdCfg.HTTPExporterEndpoint = exporterEndpoint
stdCfg.HTTPExporterInsecure = insecure
} else {
stdCfg.GRPCExporterEndpoint = exporterEndpoint
}

if !isHTTP && insecure.Valid {
stdCfg.GRPCExporterInsecure = insecure
}
}
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
}

// parseOTELEndpoint tries to parse the given string as an OTEL endpoint URL, extracts the endpoint and whether the
// connection should be secure or not. It honors what's said in the OTEL exporter docs:
// https://opentelemetry.io/docs/specs/otel/protocol/exporter/.
//
// IMPORTANT: Meant to be used only for parsing OTEL_EXPORTER_OTLP_ENDPOINT-like environment variables, not for k6
// configuration options, as Config.HTTPExporterEndpoint, for instance, doesn't accept a scheme on the URLs, while
// the OTEL exporter configuration requires it (for HTTP) or accepts it (for gRPC).
func parseOTELEndpoint(endpoint string, isHTTP bool) (null.String, null.Bool, error) {
isHTTPScheme := strings.HasPrefix(endpoint, "http://")
isHTTPSScheme := strings.HasPrefix(endpoint, "https://")

// For HTTP/S, the scheme is required. For gRPC, it's optional.
if !isHTTPScheme && !isHTTPSScheme {
if isHTTP {
return null.String{}, null.Bool{}, errors.New("endpoint must contain the scheme (http or https)")
}
// gRPC accepts any form allowed by the underlying client, so use as-is.
return null.StringFrom(endpoint), null.Bool{}, nil
}

// Parse the URL to extract components.
parsedURL, err := url.Parse(endpoint)
if err != nil {
return null.String{}, null.Bool{}, err
}

host := parsedURL.Host
port := parsedURL.Port()

if host == "" {
return null.String{}, null.Bool{}, errors.New("endpoint must contain a host")
}

// HTTP requires a port, gRPC doesn't.
if isHTTP && port == "" {
return null.String{}, null.Bool{}, errors.New("endpoint must contain host and port")
}
Comment on lines +494 to +497
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.

Where did you read that HTTP requires a port? This is not what I understand from this part (in this paragraph):

An SDK MUST NOT modify the URL in ways other than specified above. That also means, if the port is empty or not given, TCP port 80 is the default for the http scheme and TCP port 443 is the default for the https scheme, as per the usual rules for these schemes (RFC 7230).

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.

This is historical behavior from the k6 extension itself, rather than something specific to OTLP specification. See:

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

(https://grafana.com/docs/k6/latest/results-output/real-time/opentelemetry/)


But your comment made me wonder, should I perhaps append the default port, in case we get the endpoint from standard OTLP variables and it doesn't include already a port? 🤔


// From OTEL docs: A scheme of "https" indicates a secure connection and takes precedence over the insecure
// configuration setting. The same applies to "http", but indicating insecure connection.
var insecure null.Bool
if isHTTPSScheme {
insecure = null.BoolFrom(false)
} else {
insecure = null.BoolFrom(true)
}

return null.StringFrom(host), insecure, 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
}
Loading
Loading