diff --git a/data_for_test.go b/data_for_test.go index dd63f67..2401486 100644 --- a/data_for_test.go +++ b/data_for_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "github.com/equinix-labs/otel-cli/otelcli" "github.com/equinix-labs/otel-cli/otlpclient" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) @@ -55,10 +56,11 @@ type FixtureConfig struct { // mostly mirrors otelcli.StatusOutput but we need more type Results struct { // same as otelcli.StatusOutput but copied because embedding doesn't work for this - Config otlpclient.Config `json:"config"` - SpanData map[string]string `json:"span_data"` - Env map[string]string `json:"env"` - Diagnostics otlpclient.Diagnostics `json:"diagnostics"` + Config otelcli.Config `json:"config"` + SpanData map[string]string `json:"span_data"` + Env map[string]string `json:"env"` + Diagnostics otelcli.Diagnostics `json:"diagnostics"` + Errors otlpclient.ErrorList `json:"errors"` // these are specific to tests... ServerMeta map[string]string Headers map[string]string // headers sent by the client @@ -95,8 +97,8 @@ var suites = []FixtureSuite{ CliArgs: []string{"status"}, }, Expect: Results{ - Config: otlpclient.DefaultConfig(), - Diagnostics: otlpclient.Diagnostics{ + Config: otelcli.DefaultConfig(), + Diagnostics: otelcli.Diagnostics{ IsRecording: false, NumArgs: 1, ParsedTimeoutMs: 1000, @@ -115,13 +117,13 @@ var suites = []FixtureSuite{ }, Expect: Results{ // otel-cli should NOT set insecure when it auto-detects localhost - Config: otlpclient.DefaultConfig(). + Config: otelcli.DefaultConfig(). WithEndpoint("{{endpoint}}"). WithInsecure(false), ServerMeta: map[string]string{ "proto": "grpc", }, - Diagnostics: otlpclient.Diagnostics{ + Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 3, DetectedLocalhost: true, @@ -140,7 +142,7 @@ var suites = []FixtureSuite{ }, Expect: Results{ // otel-cli should NOT set insecure when it auto-detects localhost - Config: otlpclient.DefaultConfig(). + Config: otelcli.DefaultConfig(). WithEndpoint("http://{{endpoint}}"). WithInsecure(false), ServerMeta: map[string]string{ @@ -150,7 +152,7 @@ var suites = []FixtureSuite{ "proto": "HTTP/1.1", "uri": "/v1/traces", }, - Diagnostics: otlpclient.Diagnostics{ + Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 3, DetectedLocalhost: true, @@ -179,12 +181,12 @@ var suites = []FixtureSuite{ ServerTLSEnabled: true, }, Expect: Results{ - Config: otlpclient.DefaultConfig(). + Config: otelcli.DefaultConfig(). WithEndpoint("https://{{endpoint}}"). WithProtocol("grpc"). WithVerbose(true). WithTlsNoVerify(true), - Diagnostics: otlpclient.Diagnostics{ + Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 8, DetectedLocalhost: true, @@ -206,10 +208,10 @@ var suites = []FixtureSuite{ }, Expect: Results{ // otel-cli should NOT set insecure when it auto-detects localhost - Config: otlpclient.DefaultConfig(). + Config: otelcli.DefaultConfig(). WithTlsNoVerify(true). WithEndpoint("https://{{endpoint}}"), - Diagnostics: otlpclient.Diagnostics{ + Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 4, DetectedLocalhost: true, @@ -238,14 +240,14 @@ var suites = []FixtureSuite{ ServerTLSAuthEnabled: true, }, Expect: Results{ - Config: otlpclient.DefaultConfig(). + Config: otelcli.DefaultConfig(). WithEndpoint("https://{{endpoint}}"). WithProtocol("grpc"). WithTlsCACert("{{tls_ca_cert}}"). WithTlsClientKey("{{tls_client_key}}"). WithTlsClientCert("{{tls_client_cert}}"). WithVerbose(true), - Diagnostics: otlpclient.Diagnostics{ + Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 13, DetectedLocalhost: true, @@ -274,13 +276,13 @@ var suites = []FixtureSuite{ ServerTLSAuthEnabled: true, }, Expect: Results{ - Config: otlpclient.DefaultConfig(). + Config: otelcli.DefaultConfig(). WithEndpoint("https://{{endpoint}}"). WithTlsCACert("{{tls_ca_cert}}"). WithTlsClientKey("{{tls_client_key}}"). WithTlsClientCert("{{tls_client_cert}}"). WithVerbose(true), - Diagnostics: otlpclient.Diagnostics{ + Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 11, DetectedLocalhost: true, @@ -308,7 +310,7 @@ var suites = []FixtureSuite{ StopServerBeforeExec: true, // there will be no server listening }, Expect: Results{ - Config: otlpclient.DefaultConfig(), + Config: otelcli.DefaultConfig(), // we want and expect a timeout and failure TimedOut: true, CommandFailed: true, @@ -324,7 +326,7 @@ var suites = []FixtureSuite{ }, }, Expect: Results{ - Config: otlpclient.DefaultConfig(), + Config: otelcli.DefaultConfig(), CommandFailed: true, // strips the date off the log line before comparing to expectation CliOutputRe: regexp.MustCompile(`^\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} `), @@ -340,19 +342,31 @@ var suites = []FixtureSuite{ TestTimeoutMs: 1000, }, Expect: Results{ - Config: otlpclient.DefaultConfig(). + Config: otelcli.DefaultConfig(). WithEndpoint("https://{{endpoint}}"), - Diagnostics: otlpclient.Diagnostics{ + Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 3, DetectedLocalhost: true, ParsedTimeoutMs: 1000, Endpoint: "*", EndpointSource: "*", - Error: `Post "https://{{endpoint}}/v1/traces": http: server gave HTTP response to HTTPS client`, }, SpanCount: 0, }, + CheckFuncs: []CheckFunc{ + func(t *testing.T, f Fixture, r Results) { + want := injectVars(`Post "https://{{endpoint}}/v1/traces": http: server gave HTTP response to HTTPS client`, f.Endpoint, f.TlsData) + if len(r.Errors) >= 1 { + if r.Errors[0].Error != want { + t.Errorf("Got the wrong error: %q", r.Errors[0].Error) + } + } else { + t.Errorf("Expected at least one error but got %d.", len(r.Errors)) + } + + }, + }, }, }, // regression tests @@ -370,7 +384,7 @@ var suites = []FixtureSuite{ }, Expect: Results{ SpanCount: 1, - Config: otlpclient.DefaultConfig().WithEndpoint("grpc://{{endpoint}}"), + Config: otelcli.DefaultConfig().WithEndpoint("grpc://{{endpoint}}"), }, CheckFuncs: []CheckFunc{ func(t *testing.T, f Fixture, r Results) { @@ -395,14 +409,14 @@ var suites = []FixtureSuite{ }, Expect: Results{ SpanCount: 1, - Config: otlpclient.DefaultConfig().WithEndpoint("{{endpoint}}").WithTlsCACert("{{tls_ca_cert}}"), + Config: otelcli.DefaultConfig().WithEndpoint("{{endpoint}}").WithTlsCACert("{{tls_ca_cert}}"), Env: map[string]string{ "OTEL_FAKE_VARIABLE": "fake value", "OTEL_EXPORTER_OTLP_ENDPOINT": "{{endpoint}}", "OTEL_EXPORTER_OTLP_CERTIFICATE": "{{tls_ca_cert}}", "X_WHATEVER": "whatever", }, - Diagnostics: otlpclient.Diagnostics{ + Diagnostics: otelcli.Diagnostics{ IsRecording: true, DetectedLocalhost: true, NumArgs: 1, @@ -420,8 +434,8 @@ var suites = []FixtureSuite{ }, Expect: Results{ SpanCount: 1, - Config: otlpclient.DefaultConfig().WithEndpoint("http://{{endpoint}}/mycollector"), - Diagnostics: otlpclient.Diagnostics{ + Config: otelcli.DefaultConfig().WithEndpoint("http://{{endpoint}}/mycollector"), + Diagnostics: otelcli.Diagnostics{ IsRecording: true, DetectedLocalhost: true, NumArgs: 3, @@ -440,8 +454,8 @@ var suites = []FixtureSuite{ }, Expect: Results{ SpanCount: 1, - Config: otlpclient.DefaultConfig().WithTracesEndpoint("http://{{endpoint}}/mycollector/x/1"), - Diagnostics: otlpclient.Diagnostics{ + Config: otelcli.DefaultConfig().WithTracesEndpoint("http://{{endpoint}}/mycollector/x/1"), + Diagnostics: otelcli.Diagnostics{ IsRecording: true, DetectedLocalhost: true, NumArgs: 3, @@ -459,7 +473,7 @@ var suites = []FixtureSuite{ Config: FixtureConfig{ CliArgs: []string{"span", "--service", "main_test.go", "--name", "test-span-123", "--kind", "server"}, }, - Expect: Results{Config: otlpclient.DefaultConfig()}, + Expect: Results{Config: otelcli.DefaultConfig()}, }, }, // config file @@ -476,7 +490,7 @@ var suites = []FixtureSuite{ }, Expect: Results{ SpanCount: 1, - Diagnostics: otlpclient.Diagnostics{ + Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 3, ParsedTimeoutMs: 1000, @@ -488,7 +502,7 @@ var suites = []FixtureSuite{ Env: map[string]string{ "OTEL_EXPORTER_OTLP_ENDPOINT": "{{endpoint}}", }, - Config: otlpclient.DefaultConfig(). + Config: otelcli.DefaultConfig(). WithEndpoint("{{endpoint}}"). // tells the test framework to ignore/overwrite WithTimeout("1s"). WithHeaders(map[string]string{"header1": "header1-value"}). @@ -534,7 +548,7 @@ var suites = []FixtureSuite{ TestTimeoutMs: 1000, }, Expect: Results{ - Config: otlpclient.DefaultConfig(), + Config: otelcli.DefaultConfig(), SpanCount: 1, }, }, @@ -553,7 +567,7 @@ var suites = []FixtureSuite{ TestTimeoutMs: 1000, }, Expect: Results{ - Config: otlpclient.DefaultConfig(), + Config: otelcli.DefaultConfig(), SpanData: map[string]string{ "span_id": "*", "trace_id": "*", @@ -575,7 +589,7 @@ var suites = []FixtureSuite{ TestTimeoutMs: 1000, }, Expect: Results{ - Config: otlpclient.DefaultConfig(), + Config: otelcli.DefaultConfig(), SpanData: map[string]string{ "service_attributes": "service.name=test-service-123abc", }, @@ -592,7 +606,7 @@ var suites = []FixtureSuite{ Env: map[string]string{"TRACEPARENT": "00-f6c109f48195b451c4def6ab32f47b61-a5d2a35f2483004e-01"}, }, Expect: Results{ - Config: otlpclient.DefaultConfig(), + Config: otelcli.DefaultConfig(), CliOutput: "" + // empty so the text below can indent and line up "# trace id: f6c109f48195b451c4def6ab32f47b61\n" + "# span id: a5d2a35f2483004e\n" + @@ -611,7 +625,7 @@ var suites = []FixtureSuite{ }, }, Expect: Results{ - Config: otlpclient.DefaultConfig(), + Config: otelcli.DefaultConfig(), CliOutput: "" + "# trace id: f6c109f48195b451c4def6ab32f47b61\n" + "# span id: a5d2a35f2483004e\n" + @@ -630,21 +644,21 @@ var suites = []FixtureSuite{ Background: true, // sorta like & in shell Foreground: false, // must be true later, like `fg` in shell }, - Expect: Results{Config: otlpclient.DefaultConfig()}, + Expect: Results{Config: otelcli.DefaultConfig()}, }, { Name: "otel-cli span event", Config: FixtureConfig{ CliArgs: []string{"span", "event", "--name", "an event happened", "--attrs", "ima=now,mondai=problem", "--sockdir", "."}, }, - Expect: Results{Config: otlpclient.DefaultConfig()}, + Expect: Results{Config: otelcli.DefaultConfig()}, }, { Name: "otel-cli span end", Config: FixtureConfig{ CliArgs: []string{"span", "end", "--sockdir", "."}, }, - Expect: Results{Config: otlpclient.DefaultConfig()}, + Expect: Results{Config: otelcli.DefaultConfig()}, }, { // Name on foreground *must* match the backgrounded job @@ -653,7 +667,7 @@ var suites = []FixtureSuite{ Config: FixtureConfig{ Foreground: true, // bring it back (fg) and finish up }, - Expect: Results{Config: otlpclient.DefaultConfig()}, + Expect: Results{Config: otelcli.DefaultConfig()}, }, }, // otel-cli span background, in recording mode @@ -668,7 +682,7 @@ var suites = []FixtureSuite{ Foreground: false, }, Expect: Results{ - Config: otlpclient.DefaultConfig(), + Config: otelcli.DefaultConfig(), SpanData: map[string]string{ "span_id": "*", "trace_id": "*", @@ -694,7 +708,7 @@ var suites = []FixtureSuite{ Config: FixtureConfig{ CliArgs: []string{"span", "event", "--name", "an event happened", "--attrs", "ima=now,mondai=problem", "--sockdir", "."}, }, - Expect: Results{Config: otlpclient.DefaultConfig()}, + Expect: Results{Config: otelcli.DefaultConfig()}, }, { Name: "otel-cli span end", @@ -707,14 +721,14 @@ var suites = []FixtureSuite{ "--status-description", "I can't do that Dave.", }, }, - Expect: Results{Config: otlpclient.DefaultConfig()}, + Expect: Results{Config: otelcli.DefaultConfig()}, }, { Name: "otel-cli span background (recording)", Config: FixtureConfig{ Foreground: true, // fg }, - Expect: Results{Config: otlpclient.DefaultConfig()}, + Expect: Results{Config: otelcli.DefaultConfig()}, }, }, // otel-cli exec runs echo @@ -730,7 +744,7 @@ var suites = []FixtureSuite{ }, }, Expect: Results{ - Config: otlpclient.DefaultConfig(), + Config: otelcli.DefaultConfig(), SpanData: map[string]string{ "span_id": "*", "trace_id": "edededededededededededededed9000", @@ -750,7 +764,7 @@ var suites = []FixtureSuite{ "./otel-cli", "exec", "--name", "inner", "--endpoint", "{{endpoint}}", "--tp-required", "--fail", "--verbose", "echo", "hello world"}, }, Expect: Results{ - Config: otlpclient.DefaultConfig(), + Config: otelcli.DefaultConfig(), CliOutput: "hello world\n", SpanCount: 2, }, @@ -767,11 +781,11 @@ var suites = []FixtureSuite{ TestTimeoutMs: 1000, }, Expect: Results{ - Config: otlpclient.DefaultConfig().WithEndpoint("{{endpoint}}").WithProtocol("grpc"), + Config: otelcli.DefaultConfig().WithEndpoint("{{endpoint}}").WithProtocol("grpc"), ServerMeta: map[string]string{ "proto": "grpc", }, - Diagnostics: otlpclient.Diagnostics{ + Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 5, DetectedLocalhost: true, @@ -790,7 +804,7 @@ var suites = []FixtureSuite{ TestTimeoutMs: 1000, }, Expect: Results{ - Config: otlpclient.DefaultConfig().WithEndpoint("http://{{endpoint}}").WithProtocol("http/protobuf"), + Config: otelcli.DefaultConfig().WithEndpoint("http://{{endpoint}}").WithProtocol("http/protobuf"), ServerMeta: map[string]string{ "content-type": "application/x-protobuf", "host": "{{endpoint}}", @@ -798,7 +812,7 @@ var suites = []FixtureSuite{ "proto": "HTTP/1.1", "uri": "/v1/traces", }, - Diagnostics: otlpclient.Diagnostics{ + Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 5, DetectedLocalhost: true, @@ -819,8 +833,8 @@ var suites = []FixtureSuite{ CommandFailed: true, CliOutputRe: regexp.MustCompile(`^\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} `), CliOutput: "invalid protocol setting \"xxx\"\n", - Config: otlpclient.DefaultConfig().WithEndpoint("{{endpoint}}"), - Diagnostics: otlpclient.Diagnostics{ + Config: otelcli.DefaultConfig().WithEndpoint("{{endpoint}}"), + Diagnostics: otelcli.Diagnostics{ IsRecording: false, NumArgs: 7, DetectedLocalhost: true, @@ -844,14 +858,14 @@ var suites = []FixtureSuite{ }, }, Expect: Results{ - Config: otlpclient.DefaultConfig().WithEndpoint("http://{{endpoint}}").WithProtocol("grpc"), + Config: otelcli.DefaultConfig().WithEndpoint("http://{{endpoint}}").WithProtocol("grpc"), ServerMeta: map[string]string{ "proto": "grpc", }, Env: map[string]string{ "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc", }, - Diagnostics: otlpclient.Diagnostics{ + Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 3, DetectedLocalhost: true, @@ -873,7 +887,7 @@ var suites = []FixtureSuite{ }, }, Expect: Results{ - Config: otlpclient.DefaultConfig().WithEndpoint("http://{{endpoint}}").WithProtocol("http/protobuf"), + Config: otelcli.DefaultConfig().WithEndpoint("http://{{endpoint}}").WithProtocol("http/protobuf"), ServerMeta: map[string]string{ "content-type": "application/x-protobuf", "host": "{{endpoint}}", @@ -884,7 +898,7 @@ var suites = []FixtureSuite{ Env: map[string]string{ "OTEL_EXPORTER_OTLP_PROTOCOL": "http/protobuf", }, - Diagnostics: otlpclient.Diagnostics{ + Diagnostics: otelcli.Diagnostics{ IsRecording: true, NumArgs: 3, DetectedLocalhost: true, @@ -908,8 +922,8 @@ var suites = []FixtureSuite{ CommandFailed: true, CliOutputRe: regexp.MustCompile(`^\d{4}/\d{2}/\d{2} \d{2}:\d{2}:\d{2} `), CliOutput: "invalid protocol setting \"roflcopter\"\n", - Config: otlpclient.DefaultConfig().WithEndpoint("http://{{endpoint}}"), - Diagnostics: otlpclient.Diagnostics{ + Config: otelcli.DefaultConfig().WithEndpoint("http://{{endpoint}}"), + Diagnostics: otelcli.Diagnostics{ IsRecording: false, NumArgs: 3, DetectedLocalhost: true, @@ -937,14 +951,14 @@ var suites = []FixtureSuite{ }, }, Expect: Results{ - Config: otlpclient.DefaultConfig().WithEndpoint("{{endpoint}}"), + Config: otelcli.DefaultConfig().WithEndpoint("{{endpoint}}"), SpanData: map[string]string{ "trace_id": "00112233445566778899aabbccddeeff", "span_id": "beefcafefacedead", "parent_span_id": "e4e3eeb33fc4f3d3", }, SpanCount: 1, - Diagnostics: otlpclient.Diagnostics{ + Diagnostics: otelcli.Diagnostics{ NumArgs: 10, IsRecording: true, DetectedLocalhost: true, @@ -970,7 +984,7 @@ var suites = []FixtureSuite{ }, Expect: Results{ SpanCount: 1, - Config: otlpclient.DefaultConfig(). + Config: otelcli.DefaultConfig(). WithEndpoint("{{endpoint}}"). WithProtocol("grpc"). WithHeaders(map[string]string{ @@ -982,7 +996,7 @@ var suites = []FixtureSuite{ "user-agent": "*", "x-otel-cli-otlpserver-token": "abcdefgabcdefg\n", }, - Diagnostics: otlpclient.Diagnostics{ + Diagnostics: otelcli.Diagnostics{ IsRecording: true, DetectedLocalhost: true, NumArgs: 7, @@ -1005,7 +1019,7 @@ var suites = []FixtureSuite{ }, Expect: Results{ SpanCount: 1, - Config: otlpclient.DefaultConfig(). + Config: otelcli.DefaultConfig(). WithEndpoint("http://{{endpoint}}"). WithProtocol("http/protobuf"). WithHeaders(map[string]string{ @@ -1018,7 +1032,7 @@ var suites = []FixtureSuite{ "Content-Length": "232", "X-Otel-Cli-Otlpserver-Token": "abcdefgabcdefg", }, - Diagnostics: otlpclient.Diagnostics{ + Diagnostics: otelcli.Diagnostics{ IsRecording: true, DetectedLocalhost: true, NumArgs: 7, diff --git a/main.go b/main.go index 5fd4174..2e327a2 100644 --- a/main.go +++ b/main.go @@ -4,7 +4,6 @@ import ( "os" "github.com/equinix-labs/otel-cli/otelcli" - "github.com/equinix-labs/otel-cli/otlpclient" ) // these will be set by goreleaser & ldflags at build time @@ -16,5 +15,5 @@ var ( func main() { otelcli.Execute(otelcli.FormatVersion(version, commit, date)) - os.Exit(otlpclient.GetExitCode()) + os.Exit(otelcli.GetExitCode()) } diff --git a/otelcli/completion.go b/otelcli/completion.go index c952166..5fe36b4 100644 --- a/otelcli/completion.go +++ b/otelcli/completion.go @@ -4,11 +4,10 @@ import ( "log" "os" - "github.com/equinix-labs/otel-cli/otlpclient" "github.com/spf13/cobra" ) -func completionCmd(config *otlpclient.Config) *cobra.Command { +func completionCmd(config *Config) *cobra.Command { cmd := cobra.Command{ Use: "completion [bash|zsh|fish|powershell]", Short: "Generate completion script", diff --git a/otlpclient/config.go b/otelcli/config.go similarity index 86% rename from otlpclient/config.go rename to otelcli/config.go index 3acb987..ba838ba 100644 --- a/otlpclient/config.go +++ b/otelcli/config.go @@ -1,11 +1,13 @@ -package otlpclient +package otelcli import ( "encoding/csv" "encoding/json" "fmt" "log" + "net/url" "os" + "path" "reflect" "regexp" "sort" @@ -64,6 +66,8 @@ func DefaultConfig() Config { Fail: false, StatusCode: "unset", StatusDescription: "", + Version: "unset", + StartupTime: time.Now(), } } @@ -235,9 +239,9 @@ func (c Config) ToStringMap() map[string]string { } } -// IsRecording returns true if an endpoint is set and otel-cli expects to send real +// GetIsRecording returns true if an endpoint is set and otel-cli expects to send real // spans. Returns false if unconfigured and going to run inert. -func (c Config) IsRecording() bool { +func (c Config) GetIsRecording() bool { if c.Endpoint == "" && c.TracesEndpoint == "" { Diag.IsRecording = false return false @@ -280,6 +284,60 @@ func parseDuration(d string) (time.Duration, error) { return out, nil } +// ParseEndpoint takes the endpoint or signal endpoint, augments as needed +// (e.g. bare host:port for gRPC) and then parses as a URL. +// https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md#endpoint-urls-for-otlphttp +func (config Config) ParseEndpoint() (*url.URL, string) { + var endpoint, source string + var epUrl *url.URL + var err error + + // signal-specific configs get precedence over general endpoint per OTel spec + if config.TracesEndpoint != "" { + endpoint = config.TracesEndpoint + source = "signal" + } else if config.Endpoint != "" { + endpoint = config.Endpoint + source = "general" + } else { + config.SoftFail("no endpoint configuration available") + } + + parts := strings.Split(endpoint, ":") + // bare hostname? can only be grpc, prepend + if len(parts) == 1 { + epUrl, err = url.Parse("grpc://" + endpoint + ":4317") + if err != nil { + config.SoftFail("error parsing (assumed) gRPC bare host address '%s': %s", endpoint, err) + } + } else if len(parts) > 1 { // could be URI or host:port + // actual URIs + // grpc:// is only an otel-cli thing, maybe should drop it? + if parts[0] == "grpc" || parts[0] == "http" || parts[0] == "https" { + epUrl, err = url.Parse(endpoint) + if err != nil { + config.SoftFail("error parsing provided %s URI '%s': %s", source, endpoint, err) + } + } else { + // gRPC host:port + epUrl, err = url.Parse("grpc://" + endpoint) + if err != nil { + config.SoftFail("error parsing (assumed) gRPC host:port address '%s': %s", endpoint, err) + } + } + } + + // Per spec, /v1/traces is the default, appended to any url passed + // to the general endpoint + if strings.HasPrefix(epUrl.Scheme, "http") && source != "signal" && !strings.HasSuffix(epUrl.Path, "/v1/traces") { + epUrl.Path = path.Join(epUrl.Path, "/v1/traces") + } + + Diag.EndpointSource = source + Diag.Endpoint = epUrl.String() + return epUrl, source +} + // SoftLog only calls through to log if otel-cli was run with the --verbose flag. // TODO: does it make any sense to support %w? probably yes, can clean up some // diagnostics.Error touch points. @@ -371,15 +429,15 @@ func parseCkvStringMap(in string) (map[string]string, error) { return out, nil } -// ParsedSpanStartTime returns config.SpanStartTime as time.Time. -func (c Config) ParsedSpanStartTime() time.Time { +// ParseSpanStartTime returns config.SpanStartTime as time.Time. +func (c Config) ParseSpanStartTime() time.Time { t, err := c.parseTime(c.SpanStartTime, "start") c.SoftFailIfErr(err) return t } -// ParsedSpanEndTime returns config.SpanEndTime as time.Time. -func (c Config) ParsedSpanEndTime() time.Time { +// ParseSpanEndTime returns config.SpanEndTime as time.Time. +func (c Config) ParseSpanEndTime() time.Time { t, err := c.parseTime(c.SpanEndTime, "end") c.SoftFailIfErr(err) return t @@ -451,6 +509,11 @@ func (c Config) parseTime(ts, which string) (time.Time, error) { return time.Time{}, fmt.Errorf("could not parse span %s time %q as any supported format", which, ts) } +func (c Config) GetEndpoint() *url.URL { + ep, _ := c.ParseEndpoint() + return ep +} + // WithEndpoint returns the config with Endpoint set to the provided value. func (c Config) WithEndpoint(with string) Config { c.Endpoint = with @@ -469,12 +532,22 @@ func (c Config) WithProtocol(with string) Config { return c } +// GetTimeout returns the parsed --timeout value as a time.Duration. +func (c Config) GetTimeout() time.Duration { + return c.ParseCliTimeout() +} + // WithTimeout returns the config with Timeout set to the provided value. func (c Config) WithTimeout(with string) Config { c.Timeout = with return c } +// GetHeaders returns the stringmap of configured headers. +func (c Config) GetHeaders() map[string]string { + return c.Headers +} + // WithHeades returns the config with Heades set to the provided value. func (c Config) WithHeaders(with map[string]string) Config { c.Headers = with @@ -517,6 +590,11 @@ func (c Config) WithTlsClientCert(with string) Config { return c } +// GetServiceName returns the configured OTel service name. +func (c Config) GetServiceName() string { + return c.ServiceName +} + // WithServiceName returns the config with ServiceName set to the provided value. func (c Config) WithServiceName(with string) Config { c.ServiceName = with @@ -660,3 +738,25 @@ func (c Config) WithFail(with bool) Config { c.Fail = with return c } + +// Version returns the program version stored in the config. +func (c Config) GetVersion() string { + return c.Version +} + +// WithVersion returns the config with Version set to the provided value. +func (c Config) WithVersion(with string) Config { + c.Version = with + return c +} + +// GetStartupTime returns the configured startup time. +func (c Config) GetStartupTime() time.Time { + return c.StartupTime +} + +// WithStartupTime returns the config with StartupTime set to the provided value. +func (c Config) WithStartupTime(with time.Time) Config { + c.StartupTime = with + return c +} diff --git a/otelcli/config_span.go b/otelcli/config_span.go new file mode 100644 index 0000000..e5c6215 --- /dev/null +++ b/otelcli/config_span.go @@ -0,0 +1,146 @@ +package otelcli + +import ( + "encoding/hex" + "fmt" + "io" + "time" + + "github.com/equinix-labs/otel-cli/otlpclient" + "github.com/equinix-labs/otel-cli/w3c/traceparent" + tracepb "go.opentelemetry.io/proto/otlp/trace/v1" +) + +// NewProtobufSpan creates a new span and populates it with information +// from the config struct. +func (c Config) NewProtobufSpan() *tracepb.Span { + span := otlpclient.NewProtobufSpan() + if c.GetIsRecording() { + span.TraceId = otlpclient.GenerateTraceId() + span.SpanId = otlpclient.GenerateSpanId() + } + span.Name = c.SpanName + span.Kind = otlpclient.SpanKindStringToInt(c.Kind) + span.Attributes = otlpclient.StringMapAttrsToProtobuf(c.Attributes) + + now := time.Now() + if c.SpanStartTime != "" { + st := c.ParseSpanStartTime() + span.StartTimeUnixNano = uint64(st.UnixNano()) + } else { + span.StartTimeUnixNano = uint64(now.UnixNano()) + } + + if c.SpanEndTime != "" { + et := c.ParseSpanEndTime() + span.EndTimeUnixNano = uint64(et.UnixNano()) + } else { + span.EndTimeUnixNano = uint64(now.UnixNano()) + } + + if c.GetIsRecording() { + tp := c.LoadTraceparent() + if tp.Initialized { + span.TraceId = tp.TraceId + span.ParentSpanId = tp.SpanId + } + } else { + span.TraceId = otlpclient.GetEmptyTraceId() + span.SpanId = otlpclient.GetEmptySpanId() + } + + // --force-trace-id, --force-span-id and --force-parent-span-id let the user set their own trace, span & parent span ids + // these work in non-recording mode and will stomp trace id from the traceparent + var err error + if c.ForceTraceId != "" { + span.TraceId, err = parseHex(c.ForceTraceId, 16) + c.SoftFailIfErr(err) + } + if c.ForceSpanId != "" { + span.SpanId, err = parseHex(c.ForceSpanId, 8) + c.SoftFailIfErr(err) + } + if c.ForceParentSpanId != "" { + span.ParentSpanId, err = parseHex(c.ForceParentSpanId, 8) + c.SoftFailIfErr(err) + } + + otlpclient.SetSpanStatus(span, c.StatusCode, c.StatusDescription) + + return span +} + +// LoadTraceparent follows otel-cli's loading rules, start with envvar then file. +// If both are set, the file will override env. +// When in non-recording mode, the previous traceparent will be returned if it's +// available, otherwise, a zero-valued traceparent is returned. +func (c Config) LoadTraceparent() traceparent.Traceparent { + tp := traceparent.Traceparent{ + Version: 0, + TraceId: otlpclient.GetEmptyTraceId(), + SpanId: otlpclient.GetEmptySpanId(), + Sampling: false, + Initialized: true, + } + + if !c.TraceparentIgnoreEnv { + var err error + tp, err = traceparent.LoadFromEnv() + if err != nil { + Diag.Error = err.Error() + } + } + + if c.TraceparentCarrierFile != "" { + fileTp, err := traceparent.LoadFromFile(c.TraceparentCarrierFile) + if err != nil { + Diag.Error = err.Error() + } else if fileTp.Initialized { + tp = fileTp + } + } + + if c.TraceparentRequired { + if tp.Initialized { + return tp + } else { + c.SoftFail("failed to find a valid traceparent carrier in either environment for file '%s' while it's required by --tp-required", c.TraceparentCarrierFile) + } + } + + return tp +} + +// PropagateTraceparent saves the traceparent to file if necessary, then prints +// span info to the console according to command-line args. +func (c Config) PropagateTraceparent(span *tracepb.Span, target io.Writer) { + var tp traceparent.Traceparent + if c.GetIsRecording() { + tp = otlpclient.TraceparentFromProtobufSpan(span, c.GetIsRecording()) + } else { + // when in non-recording mode, and there is a TP available, propagate that + tp = c.LoadTraceparent() + } + + if c.TraceparentCarrierFile != "" { + err := tp.SaveToFile(c.TraceparentCarrierFile, c.TraceparentPrintExport) + c.SoftFailIfErr(err) + } + + if c.TraceparentPrint { + tp.Fprint(target, c.TraceparentPrintExport) + } +} + +// parseHex parses hex into a []byte of length provided. Errors if the input is +// not valid hex or the converted hex is not the right number of bytes. +func parseHex(in string, expectedLen int) ([]byte, error) { + out, err := hex.DecodeString(in) + if err != nil { + return nil, fmt.Errorf("error parsing hex string %q: %w", in, err) + } + if len(out) != expectedLen { + return nil, fmt.Errorf("hex string %q is the wrong length, expected %d bytes but got %d", in, expectedLen, len(out)) + } + return out, nil +} diff --git a/otelcli/config_span_test.go b/otelcli/config_span_test.go new file mode 100644 index 0000000..ecd8c09 --- /dev/null +++ b/otelcli/config_span_test.go @@ -0,0 +1,54 @@ +package otelcli + +import ( + "bytes" + "encoding/hex" + "fmt" + "os" + "testing" + + "github.com/equinix-labs/otel-cli/otlpclient" +) + +func TestPropagateTraceparent(t *testing.T) { + config := DefaultConfig(). + WithTraceparentCarrierFile(""). + WithTraceparentPrint(false). + WithTraceparentPrintExport(false) + + tp := "00-3433d5ae39bdfee397f44be5146867b3-8a5518f1e5c54d0a-01" + tid := "3433d5ae39bdfee397f44be5146867b3" + sid := "8a5518f1e5c54d0a" + os.Setenv("TRACEPARENT", tp) + + span := otlpclient.NewProtobufSpan() + span.TraceId, _ = hex.DecodeString(tid) + span.SpanId, _ = hex.DecodeString(sid) + + buf := new(bytes.Buffer) + config.PropagateTraceparent(span, buf) + if buf.Len() != 0 { + t.Errorf("nothing was supposed to be written but %d bytes were", buf.Len()) + } + + config.TraceparentPrint = true + config.TraceparentPrintExport = true + buf = new(bytes.Buffer) + config.PropagateTraceparent(span, buf) + if buf.Len() == 0 { + t.Error("expected more than zero bytes but got none") + } + expected := fmt.Sprintf("# trace id: %s\n# span id: %s\nexport TRACEPARENT=%s\n", tid, sid, tp) + if buf.String() != expected { + t.Errorf("got unexpected output, expected '%s', got '%s'", expected, buf.String()) + } +} + +func TestNewProtobufSpanWithConfig(t *testing.T) { + c := DefaultConfig().WithSpanName("test span 123") + span := c.NewProtobufSpan() + + if span.Name != "test span 123" { + t.Error("span event attributes must not be nil") + } +} diff --git a/otlpclient/config_test.go b/otelcli/config_test.go similarity index 79% rename from otlpclient/config_test.go rename to otelcli/config_test.go index 7a9c1d6..4c9887f 100644 --- a/otlpclient/config_test.go +++ b/otelcli/config_test.go @@ -1,4 +1,4 @@ -package otlpclient +package otelcli import ( "testing" @@ -28,12 +28,12 @@ func TestConfig_ToStringMap(t *testing.T) { func TestIsRecording(t *testing.T) { c := DefaultConfig() - if c.IsRecording() { + if c.GetIsRecording() { t.Fail() } c.Endpoint = "https://localhost:4318" - if !c.IsRecording() { + if !c.GetIsRecording() { t.Fail() } } @@ -171,6 +171,75 @@ func TestParseCliTime(t *testing.T) { } } +func TestParseEndpoint(t *testing.T) { + // func parseEndpoint(config Config) (*url.URL, string) { + + for _, tc := range []struct { + config Config + wantEndpoint string + wantSource string + }{ + // gRPC, general, bare host + { + config: DefaultConfig().WithEndpoint("localhost"), + wantEndpoint: "grpc://localhost:4317", + wantSource: "general", + }, + // gRPC, general, should be bare host:port + { + config: DefaultConfig().WithEndpoint("localhost:4317"), + wantEndpoint: "grpc://localhost:4317", + wantSource: "general", + }, + // gRPC, general, https URL, should transform to host:port + { + config: DefaultConfig().WithEndpoint("https://localhost:4317").WithProtocol("grpc"), + wantEndpoint: "https://localhost:4317/v1/traces", + wantSource: "general", + }, + // HTTP, general, with a provided default signal path, should not be modified + { + config: DefaultConfig().WithEndpoint("http://localhost:9999/v1/traces"), + wantEndpoint: "http://localhost:9999/v1/traces", + wantSource: "general", + }, + // HTTP, general, with a provided custom signal path, signal path should get appended + { + config: DefaultConfig().WithEndpoint("http://localhost:9999/my/collector/path"), + wantEndpoint: "http://localhost:9999/my/collector/path/v1/traces", + wantSource: "general", + }, + // HTTPS, general, without path, should get /v1/traces appended + { + config: DefaultConfig().WithEndpoint("https://localhost:4317"), + wantEndpoint: "https://localhost:4317/v1/traces", + wantSource: "general", + }, + // gRPC, signal, should come through with just the grpc:// added + { + config: DefaultConfig().WithTracesEndpoint("localhost"), + wantEndpoint: "grpc://localhost:4317", + wantSource: "signal", + }, + // http, signal, should come through unmodified + { + config: DefaultConfig().WithTracesEndpoint("http://localhost"), + wantEndpoint: "http://localhost", + wantSource: "signal", + }, + } { + u, src := tc.config.ParseEndpoint() + + if u.String() != tc.wantEndpoint { + t.Errorf("Expected endpoint %q but got %q", tc.wantEndpoint, u.String()) + } + + if src != tc.wantSource { + t.Errorf("Expected source %q for test url %q but got %q", tc.wantSource, u.String(), src) + } + } +} + func TestWithEndpoint(t *testing.T) { if DefaultConfig().WithEndpoint("foobar").Endpoint != "foobar" { t.Fail() diff --git a/otelcli/config_tls.go b/otelcli/config_tls.go new file mode 100644 index 0000000..419e495 --- /dev/null +++ b/otelcli/config_tls.go @@ -0,0 +1,109 @@ +package otelcli + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net" + "net/url" + "os" +) + +// TlsConfig evaluates otel-cli configuration and returns a tls.Config +// that can be used by grpc or https. +func (config Config) GetTlsConfig() *tls.Config { + tlsConfig := &tls.Config{} + + if config.TlsNoVerify { + Diag.InsecureSkipVerify = true + tlsConfig.InsecureSkipVerify = true + } + + // puts the provided CA certificate into the root pool + // when not provided, Go TLS will automatically load the system CA pool + if config.TlsCACert != "" { + data, err := os.ReadFile(config.TlsCACert) + if err != nil { + config.SoftFail("failed to load CA certificate: %s", err) + } + + certpool := x509.NewCertPool() + certpool.AppendCertsFromPEM(data) + tlsConfig.RootCAs = certpool + } + + // client certificate authentication + if config.TlsClientCert != "" && config.TlsClientKey != "" { + clientPEM, err := os.ReadFile(config.TlsClientCert) + if err != nil { + config.SoftFail("failed to read client certificate file %s: %s", config.TlsClientCert, err) + } + clientKeyPEM, err := os.ReadFile(config.TlsClientKey) + if err != nil { + config.SoftFail("failed to read client key file %s: %s", config.TlsClientKey, err) + } + certPair, err := tls.X509KeyPair(clientPEM, clientKeyPEM) + if err != nil { + config.SoftFail("failed to parse client cert pair: %s", err) + } + tlsConfig.Certificates = []tls.Certificate{certPair} + } else if config.TlsClientCert != "" { + config.SoftFail("client cert and key must be specified together") + } else if config.TlsClientKey != "" { + config.SoftFail("client cert and key must be specified together") + } + + return tlsConfig +} + +// GetInsecure returns true if the configuration expects a non-TLS connection. +func (c Config) GetInsecure() bool { + endpointURL := c.GetEndpoint() + + isLoopback, err := isLoopbackAddr(endpointURL) + c.SoftFailIfErr(err) + + // Go's TLS does the right thing and forces us to say we want to disable encryption, + // but I expect most users of this program to point at a localhost endpoint that might not + // have any encryption available, or setting it up raises the bar of entry too high. + // The compromise is to automatically flip this flag to true when endpoint contains an + // an obvious "localhost", "127.0.0.x", or "::1" address. + if c.Insecure || (isLoopback && endpointURL.Scheme != "https") { + return true + } else if endpointURL.Scheme == "http" || endpointURL.Scheme == "unix" { + return true + } + + return false +} + +// isLoopbackAddr takes a url.URL, looks up the address, then returns true +// if it points at either a v4 or v6 loopback address. +// As I understood the OTLP spec, only host:port or an HTTP URL are acceptable. +// This function is _not_ meant to validate the endpoint, that will happen when +// otel-go attempts to connect to the endpoint. +func isLoopbackAddr(u *url.URL) (bool, error) { + hostname := u.Hostname() + + if hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" { + Diag.DetectedLocalhost = true + return true, nil + } + + ips, err := net.LookupIP(hostname) + if err != nil { + return false, fmt.Errorf("unable to look up hostname '%s': %s", hostname, err) + } + + // all ips returned must be loopback to return true + // cases where that isn't true should be super rare, and probably all shenanigans + allAreLoopback := true + for _, ip := range ips { + if !ip.IsLoopback() { + allAreLoopback = false + } + } + + Diag.DetectedLocalhost = allAreLoopback + return allAreLoopback, nil +} diff --git a/otlpclient/diagnostics.go b/otelcli/diagnostics.go similarity index 99% rename from otlpclient/diagnostics.go rename to otelcli/diagnostics.go index f617b13..151545b 100644 --- a/otlpclient/diagnostics.go +++ b/otelcli/diagnostics.go @@ -1,4 +1,4 @@ -package otlpclient +package otelcli import ( "strconv" diff --git a/otelcli/exec.go b/otelcli/exec.go index f13a051..9c69201 100644 --- a/otelcli/exec.go +++ b/otelcli/exec.go @@ -14,7 +14,7 @@ import ( ) // execCmd sets up the `otel-cli exec` command -func execCmd(config *otlpclient.Config) *cobra.Command { +func execCmd(config *Config) *cobra.Command { cmd := cobra.Command{ Use: "exec", Short: "execute the command provided", @@ -45,7 +45,7 @@ to sh -c and should not be passed any untrusted input`, func doExec(cmd *cobra.Command, args []string) { ctx := cmd.Context() config := getConfig(ctx) - ctx, client := otlpclient.StartClient(ctx, config) + ctx, client := StartClient(ctx, config) // put the command in the attributes, before creating the span so it gets picked up config.Attributes["command"] = args[0] @@ -79,15 +79,15 @@ func doExec(cmd *cobra.Command, args []string) { } } - span := otlpclient.NewProtobufSpanWithConfig(config) + span := config.NewProtobufSpan() // set the traceparent to the current span to be available to the child process - if config.IsRecording() { - tp := otlpclient.TraceparentFromProtobufSpan(config, span) + if config.GetIsRecording() { + tp := otlpclient.TraceparentFromProtobufSpan(span, config.GetIsRecording()) child.Env = append(child.Env, fmt.Sprintf("TRACEPARENT=%s", tp.Encode())) // when not recording, and a traceparent is available, pass it through } else if !config.TraceparentIgnoreEnv { - tp := otlpclient.LoadTraceparent(config, span) + tp := config.LoadTraceparent() if tp.Initialized { child.Env = append(child.Env, fmt.Sprintf("TRACEPARENT=%s", tp.Encode())) } @@ -109,7 +109,7 @@ func doExec(cmd *cobra.Command, args []string) { } // set the global exit code so main() can grab it and os.Exit() properly - otlpclient.Diag.ExecExitCode = child.ProcessState.ExitCode() + Diag.ExecExitCode = child.ProcessState.ExitCode() - otlpclient.PropagateTraceparent(config, span, os.Stdout) + config.PropagateTraceparent(span, os.Stdout) } diff --git a/otelcli/otlpclient.go b/otelcli/otlpclient.go new file mode 100644 index 0000000..7bcd92a --- /dev/null +++ b/otelcli/otlpclient.go @@ -0,0 +1,43 @@ +package otelcli + +import ( + "context" + "fmt" + "strings" + + "github.com/equinix-labs/otel-cli/otlpclient" +) + +// StartClient uses the Config to setup and start either a gRPC or HTTP client, +// and returns the OTLPClient interface to them. +func StartClient(ctx context.Context, config Config) (context.Context, otlpclient.OTLPClient) { + if !config.GetIsRecording() { + return ctx, otlpclient.NewNullClient(config) + } + + if config.Protocol != "" && config.Protocol != "grpc" && config.Protocol != "http/protobuf" { + err := fmt.Errorf("invalid protocol setting %q", config.Protocol) + Diag.Error = err.Error() + config.SoftFail(err.Error()) + } + + endpointURL := config.GetEndpoint() + + var client otlpclient.OTLPClient + if config.Protocol != "grpc" && + (strings.HasPrefix(config.Protocol, "http/") || + endpointURL.Scheme == "http" || + endpointURL.Scheme == "https") { + client = otlpclient.NewHttpClient(config) + } else { + client = otlpclient.NewGrpcClient(config) + } + + ctx, err := client.Start(ctx) + if err != nil { + Diag.Error = err.Error() + config.SoftFail("Failed to start OTLP client: %s", err) + } + + return ctx, client +} diff --git a/otelcli/root.go b/otelcli/root.go index 5882977..ceef96e 100644 --- a/otelcli/root.go +++ b/otelcli/root.go @@ -7,11 +7,10 @@ import ( "os" "time" - "github.com/equinix-labs/otel-cli/otlpclient" "github.com/spf13/cobra" ) -// cliContextKey is a type for storing an otlpclient.Config in context. +// cliContextKey is a type for storing an Config in context. type cliContextKey string // configContextKey returns the typed key for storing/retrieving config in context. @@ -21,9 +20,9 @@ func configContextKey() cliContextKey { // getConfigRef retrieves the otelcli.Config from the context and returns a // pointer to it. -func getConfigRef(ctx context.Context) *otlpclient.Config { +func getConfigRef(ctx context.Context) *Config { if cv := ctx.Value(configContextKey()); cv != nil { - if c, ok := cv.(*otlpclient.Config); ok { + if c, ok := cv.(*Config); ok { return c } else { panic("BUG: failed to unwrap config that was in context, please report an issue") @@ -34,14 +33,14 @@ func getConfigRef(ctx context.Context) *otlpclient.Config { } // getConfig retrieves the otelcli.Config from context and returns a copy. -func getConfig(ctx context.Context) otlpclient.Config { +func getConfig(ctx context.Context) Config { config := getConfigRef(ctx) return *config } // createRootCmd builds up the Cobra command-line, calling through to subcommand // builder funcs to build the whole tree. -func createRootCmd(config *otlpclient.Config) *cobra.Command { +func createRootCmd(config *Config) *cobra.Command { // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ Use: "otel-cli", @@ -62,10 +61,10 @@ func createRootCmd(config *otlpclient.Config) *cobra.Command { cobra.EnableCommandSorting = false rootCmd.Flags().SortFlags = false - otlpclient.Diag.NumArgs = len(os.Args) - 1 - otlpclient.Diag.CliArgs = []string{} + Diag.NumArgs = len(os.Args) - 1 + Diag.CliArgs = []string{} if len(os.Args) > 1 { - otlpclient.Diag.CliArgs = os.Args[1:] + Diag.CliArgs = os.Args[1:] } // add all the subcommands to rootCmd @@ -81,7 +80,7 @@ func createRootCmd(config *otlpclient.Config) *cobra.Command { // Execute adds all child commands to the root command and sets flags appropriately. // This is called by main.main(). It only needs to happen once. func Execute(version string) { - config := otlpclient.DefaultConfig() + config := DefaultConfig() config.StartupTime = time.Now() // record startup time as early as possible timeouts config.Version = version @@ -93,8 +92,8 @@ func Execute(version string) { } // addCommonParams adds the --config and --endpoint params to the command. -func addCommonParams(cmd *cobra.Command, config *otlpclient.Config) { - defaults := otlpclient.DefaultConfig() +func addCommonParams(cmd *cobra.Command, config *Config) { + defaults := DefaultConfig() // --config / -c a JSON configuration file cmd.Flags().StringVarP(&config.CfgFile, "config", "c", defaults.CfgFile, "JSON configuration file") @@ -116,13 +115,16 @@ func addCommonParams(cmd *cobra.Command, config *otlpclient.Config) { // envvars are named according to the otel specs, others use the OTEL_CLI prefix // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/sdk-environment-variables.md // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md -func addClientParams(cmd *cobra.Command, config *otlpclient.Config) { - defaults := otlpclient.DefaultConfig() +func addClientParams(cmd *cobra.Command, config *Config) { + defaults := DefaultConfig() config.Headers = make(map[string]string) // OTEL_EXPORTER standard env and variable params cmd.Flags().StringToStringVar(&config.Headers, "otlp-headers", defaults.Headers, "a comma-sparated list of key=value headers to send on OTLP connection") - cmd.Flags().BoolVar(&config.Blocking, "otlp-blocking", defaults.Blocking, "block on connecting to the OTLP server before proceeding") + + // DEPRECATED + // TODO: remove before 1.0 + cmd.Flags().BoolVar(&config.Blocking, "otlp-blocking", defaults.Blocking, "DEPRECATED: does nothing, please file an issue if you need this.") cmd.Flags().BoolVar(&config.Insecure, "insecure", defaults.Insecure, "allow connecting to cleartext endpoints") cmd.Flags().StringVar(&config.TlsCACert, "tls-ca-cert", defaults.TlsCACert, "a file containing the certificate authority bundle") @@ -140,8 +142,8 @@ func addClientParams(cmd *cobra.Command, config *otlpclient.Config) { cmd.Flags().BoolVarP(&config.TraceparentPrintExport, "tp-export", "p", defaults.TraceparentPrintExport, "same as --tp-print but it puts an 'export ' in front so it's more convinenient to source in scripts") } -func addSpanParams(cmd *cobra.Command, config *otlpclient.Config) { - defaults := otlpclient.DefaultConfig() +func addSpanParams(cmd *cobra.Command, config *Config) { + defaults := DefaultConfig() // --name / -s cmd.Flags().StringVarP(&config.SpanName, "name", "n", defaults.SpanName, "set the name of the span") @@ -158,8 +160,8 @@ func addSpanParams(cmd *cobra.Command, config *otlpclient.Config) { addSpanStatusParams(cmd, config) } -func addSpanStartEndParams(cmd *cobra.Command, config *otlpclient.Config) { - defaults := otlpclient.DefaultConfig() +func addSpanStartEndParams(cmd *cobra.Command, config *Config) { + defaults := DefaultConfig() // --start $timestamp (RFC3339 or Unix_Epoch.Nanos) cmd.Flags().StringVar(&config.SpanStartTime, "start", defaults.SpanStartTime, "a Unix epoch or RFC3339 timestamp for the start of the span") @@ -168,8 +170,8 @@ func addSpanStartEndParams(cmd *cobra.Command, config *otlpclient.Config) { cmd.Flags().StringVar(&config.SpanEndTime, "end", defaults.SpanEndTime, "an Unix epoch or RFC3339 timestamp for the end of the span") } -func addSpanStatusParams(cmd *cobra.Command, config *otlpclient.Config) { - defaults := otlpclient.DefaultConfig() +func addSpanStatusParams(cmd *cobra.Command, config *Config) { + defaults := DefaultConfig() // --status-code / -sc cmd.Flags().StringVar(&config.StatusCode, "status-code", defaults.StatusCode, "set the span status code, e.g. unset|ok|error") @@ -177,8 +179,8 @@ func addSpanStatusParams(cmd *cobra.Command, config *otlpclient.Config) { cmd.Flags().StringVar(&config.StatusDescription, "status-description", defaults.StatusDescription, "set the span status description when a span status code of error is set, e.g. 'cancelled'") } -func addAttrParams(cmd *cobra.Command, config *otlpclient.Config) { - defaults := otlpclient.DefaultConfig() +func addAttrParams(cmd *cobra.Command, config *Config) { + defaults := DefaultConfig() // --attrs key=value,foo=bar config.Attributes = make(map[string]string) cmd.Flags().StringToStringVarP(&config.Attributes, "attrs", "a", defaults.Attributes, "a comma-separated list of key=value attributes") diff --git a/otelcli/server.go b/otelcli/server.go index 2859a19..cbcd6c1 100644 --- a/otelcli/server.go +++ b/otelcli/server.go @@ -3,7 +3,6 @@ package otelcli import ( "strings" - "github.com/equinix-labs/otel-cli/otlpclient" "github.com/equinix-labs/otel-cli/otlpserver" "github.com/spf13/cobra" ) @@ -11,7 +10,7 @@ import ( const defaultOtlpEndpoint = "grpc://localhost:4317" const spanBgSockfilename = "otel-cli-background.sock" -func serverCmd(config *otlpclient.Config) *cobra.Command { +func serverCmd(config *Config) *cobra.Command { cmd := cobra.Command{ Use: "server", Short: "run an embedded OTLP server", @@ -26,12 +25,12 @@ func serverCmd(config *otlpclient.Config) *cobra.Command { // runServer runs the server on either grpc or http and blocks until the server // stops or is killed. -func runServer(config otlpclient.Config, cb otlpserver.Callback, stop otlpserver.Stopper) { +func runServer(config Config, cb otlpserver.Callback, stop otlpserver.Stopper) { // unlike the rest of otel-cli, server should default to localhost:4317 if config.Endpoint == "" { config.Endpoint = defaultOtlpEndpoint } - endpointURL, _ := otlpclient.ParseEndpoint(config) + endpointURL, _ := config.ParseEndpoint() var cs otlpserver.OtlpServer if config.Protocol != "grpc" && diff --git a/otelcli/server_json.go b/otelcli/server_json.go index d61ad77..b37864d 100644 --- a/otelcli/server_json.go +++ b/otelcli/server_json.go @@ -10,7 +10,6 @@ import ( "strconv" "time" - "github.com/equinix-labs/otel-cli/otlpclient" "github.com/equinix-labs/otel-cli/otlpserver" "github.com/spf13/cobra" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" @@ -24,7 +23,7 @@ var jsonSvr struct { spansSeen int } -func serverJsonCmd(config *otlpclient.Config) *cobra.Command { +func serverJsonCmd(config *Config) *cobra.Command { cmd := cobra.Command{ Use: "json", Short: "write spans to json or stdout", diff --git a/otelcli/server_tui.go b/otelcli/server_tui.go index 43152e2..512ecf8 100644 --- a/otelcli/server_tui.go +++ b/otelcli/server_tui.go @@ -21,7 +21,7 @@ var tuiServer struct { area *pterm.AreaPrinter } -func serverTuiCmd(config *otlpclient.Config) *cobra.Command { +func serverTuiCmd(config *Config) *cobra.Command { cmd := cobra.Command{ Use: "tui", Short: "display spans in a terminal UI", diff --git a/otelcli/span.go b/otelcli/span.go index 9efcbe9..7e45df6 100644 --- a/otelcli/span.go +++ b/otelcli/span.go @@ -8,7 +8,7 @@ import ( ) // spanCmd represents the span command -func spanCmd(config *otlpclient.Config) *cobra.Command { +func spanCmd(config *Config) *cobra.Command { cmd := cobra.Command{ Use: "span", Short: "create an OpenTelemetry span and send it", @@ -47,11 +47,11 @@ Example: func doSpan(cmd *cobra.Command, args []string) { ctx := cmd.Context() config := getConfig(ctx) - ctx, client := otlpclient.StartClient(ctx, config) - span := otlpclient.NewProtobufSpanWithConfig(config) + ctx, client := StartClient(ctx, config) + span := config.NewProtobufSpan() ctx, err := otlpclient.SendSpan(ctx, client, config, span) config.SoftFailIfErr(err) _, err = client.Stop(ctx) config.SoftFailIfErr(err) - otlpclient.PropagateTraceparent(config, span, os.Stdout) + config.PropagateTraceparent(span, os.Stdout) } diff --git a/otelcli/span_background.go b/otelcli/span_background.go index ce0d28c..9a2ad4a 100644 --- a/otelcli/span_background.go +++ b/otelcli/span_background.go @@ -15,7 +15,7 @@ import ( ) // spanBgCmd represents the span background command -func spanBgCmd(config *otlpclient.Config) *cobra.Command { +func spanBgCmd(config *Config) *cobra.Command { cmd := cobra.Command{ Use: "background", Short: "create background span handler", @@ -39,7 +39,7 @@ timeout, (catchable) signals, or deliberate exit. Run: doSpanBackground, } - defaults := otlpclient.DefaultConfig() + defaults := DefaultConfig() cmd.Flags().SortFlags = false // don't sort subcommands // it seems like the socket should be required for background but it's @@ -64,7 +64,7 @@ func doSpanBackground(cmd *cobra.Command, args []string) { ctx := cmd.Context() config := getConfig(ctx) started := time.Now() - ctx, client := otlpclient.StartClient(ctx, config) + ctx, client := StartClient(ctx, config) // special case --wait, createBgClient() will wait for the socket to show up // then connect and send a no-op RPC. by this time e.g. --tp-carrier should @@ -79,12 +79,12 @@ func doSpanBackground(cmd *cobra.Command, args []string) { return } - span := otlpclient.NewProtobufSpanWithConfig(config) + span := config.NewProtobufSpan() // span background is a bit different from span/exec in that it might be // hanging out while other spans are created, so it does the traceparent // propagation before the server starts, instead of after - otlpclient.PropagateTraceparent(config, span, os.Stdout) + config.PropagateTraceparent(span, os.Stdout) sockfile := path.Join(config.BackgroundSockdir, spanBgSockfilename) bgs := createBgServer(ctx, sockfile, span) diff --git a/otelcli/span_background_server.go b/otelcli/span_background_server.go index 363bcfe..88ba79e 100644 --- a/otelcli/span_background_server.go +++ b/otelcli/span_background_server.go @@ -22,7 +22,7 @@ type BgSpan struct { SpanID string `json:"span_id"` Traceparent string `json:"traceparent"` Error string `json:"error"` - config otlpclient.Config + config Config span *tracepb.Span shutdown func() } @@ -44,7 +44,7 @@ type BgEnd struct { func (bs BgSpan) AddEvent(bse *BgSpanEvent, reply *BgSpan) error { reply.TraceID = hex.EncodeToString(bs.span.TraceId) reply.SpanID = hex.EncodeToString(bs.span.SpanId) - reply.Traceparent = otlpclient.TraceparentFromProtobufSpan(bs.config, bs.span).Encode() + reply.Traceparent = otlpclient.TraceparentFromProtobufSpan(bs.span, bs.config.GetIsRecording()).Encode() ts, err := time.Parse(time.RFC3339Nano, bse.Timestamp) if err != nil { @@ -72,7 +72,7 @@ func (bs BgSpan) Wait(in, reply *struct{}) error { func (bs BgSpan) End(in *BgEnd, reply *BgSpan) error { // handle --status-code and --status-description args to span end c := bs.config.WithStatusCode(in.StatusCode).WithStatusDescription(in.StatusDesc) - otlpclient.SetSpanStatus(bs.span, c) + otlpclient.SetSpanStatus(bs.span, c.StatusCode, c.StatusDescription) // running the shutdown as a goroutine prevents the client from getting an // error here when the server gets closed. defer didn't do the trick. @@ -86,7 +86,7 @@ type bgServer struct { listener net.Listener quit chan struct{} wg sync.WaitGroup - config otlpclient.Config + config Config } // createBgServer opens a new span background server on a unix socket and @@ -160,7 +160,7 @@ func (bgs *bgServer) Shutdown() { // createBgClient sets up a client connection to the unix socket jsonrpc server // and returns the rpc client handle and a shutdown function that should be // deferred. -func createBgClient(config otlpclient.Config) (*rpc.Client, func()) { +func createBgClient(config Config) (*rpc.Client, func()) { sockfile := path.Join(config.BackgroundSockdir, spanBgSockfilename) started := time.Now() timeout := config.ParseCliTimeout() diff --git a/otelcli/span_end.go b/otelcli/span_end.go index 4dde467..13aa847 100644 --- a/otelcli/span_end.go +++ b/otelcli/span_end.go @@ -3,13 +3,12 @@ package otelcli import ( "os" - "github.com/equinix-labs/otel-cli/otlpclient" "github.com/equinix-labs/otel-cli/w3c/traceparent" "github.com/spf13/cobra" ) // spanEndCmd represents the span event command -func spanEndCmd(config *otlpclient.Config) *cobra.Command { +func spanEndCmd(config *Config) *cobra.Command { cmd := cobra.Command{ Use: "end", Short: "Make a span background to end itself and exit gracefully", @@ -22,7 +21,7 @@ See: otel-cli span background Run: doSpanEnd, } - defaults := otlpclient.DefaultConfig() + defaults := DefaultConfig() cmd.Flags().BoolVar(&config.Verbose, "verbose", defaults.Verbose, "print errors on failure instead of always being silent") // TODO diff --git a/otelcli/span_event.go b/otelcli/span_event.go index 81e689f..5bb4830 100644 --- a/otelcli/span_event.go +++ b/otelcli/span_event.go @@ -4,13 +4,12 @@ import ( "os" "time" - "github.com/equinix-labs/otel-cli/otlpclient" "github.com/equinix-labs/otel-cli/w3c/traceparent" "github.com/spf13/cobra" ) // spanEventCmd represents the span event command -func spanEventCmd(config *otlpclient.Config) *cobra.Command { +func spanEventCmd(config *Config) *cobra.Command { cmd := cobra.Command{ Use: "event", Short: "create an OpenTelemetry span event and add it to the background span", @@ -29,7 +28,7 @@ See: otel-cli span background Run: doSpanEvent, } - defaults := otlpclient.DefaultConfig() + defaults := DefaultConfig() cmd.Flags().SortFlags = false @@ -48,7 +47,7 @@ See: otel-cli span background func doSpanEvent(cmd *cobra.Command, args []string) { config := getConfig(cmd.Context()) - timestamp := otlpclient.DefaultConfig().ParsedEventTime() + timestamp := config.ParsedEventTime() rpcArgs := BgSpanEvent{ Name: config.EventName, Timestamp: timestamp.Format(time.RFC3339Nano), diff --git a/otelcli/status.go b/otelcli/status.go index c7a8f12..502d8cb 100644 --- a/otelcli/status.go +++ b/otelcli/status.go @@ -17,15 +17,15 @@ import ( // StatusOutput captures all the data we want to print out for this subcommand // and is also used in ../main_test.go for automated testing. type StatusOutput struct { - Config otlpclient.Config `json:"config"` - Spans []map[string]string `json:"spans"` - SpanData map[string]string `json:"span_data"` - Env map[string]string `json:"env"` - Diagnostics otlpclient.Diagnostics `json:"diagnostics"` - Errors otlpclient.ErrorList `json:"errors"` + Config Config `json:"config"` + Spans []map[string]string `json:"spans"` + SpanData map[string]string `json:"span_data"` + Env map[string]string `json:"env"` + Diagnostics Diagnostics `json:"diagnostics"` + Errors otlpclient.ErrorList `json:"errors"` } -func statusCmd(config *otlpclient.Config) *cobra.Command { +func statusCmd(config *Config) *cobra.Command { cmd := cobra.Command{ Use: "status", Short: "send at least one canary and dump status", @@ -42,7 +42,7 @@ Example: Run: doStatus, } - defaults := otlpclient.DefaultConfig() + defaults := DefaultConfig() cmd.Flags().IntVar(&config.StatusCanaryCount, "canary-count", defaults.StatusCanaryCount, "number of canaries to send") cmd.Flags().StringVar(&config.StatusCanaryInterval, "canary-interval", defaults.StatusCanaryInterval, "number of milliseconds to wait between canaries") @@ -60,7 +60,7 @@ func doStatus(cmd *cobra.Command, args []string) { ctx := cmd.Context() config := getConfig(ctx) - ctx, client := otlpclient.StartClient(ctx, config) + ctx, client := StartClient(ctx, config) env := make(map[string]string) for _, e := range os.Environ() { @@ -93,7 +93,7 @@ func doStatus(cmd *cobra.Command, args []string) { break } - span := otlpclient.NewProtobufSpanWithConfig(config) + span := config.NewProtobufSpan() span.Name = "otel-cli status" if canaryCount > 0 { span.Name = fmt.Sprintf("otel-cli status canary %d", canaryCount) @@ -142,11 +142,11 @@ func doStatus(cmd *cobra.Command, args []string) { SpanData: map[string]string{ "trace_id": hex.EncodeToString(lastSpan.TraceId), "span_id": hex.EncodeToString(lastSpan.SpanId), - "is_sampled": strconv.FormatBool(config.IsRecording()), + "is_sampled": strconv.FormatBool(config.GetIsRecording()), }, // Diagnostics is deprecated, being replaced by Errors below and eventually // another stringmap of stuff that was tunneled through context.Context - Diagnostics: otlpclient.Diag, + Diagnostics: Diag, Errors: errorList, } diff --git a/otlpclient/otlp_client.go b/otlpclient/otlp_client.go index 2dc357e..485d7f6 100644 --- a/otlpclient/otlp_client.go +++ b/otlpclient/otlp_client.go @@ -5,13 +5,8 @@ package otlpclient import ( "context" "crypto/tls" - "crypto/x509" "fmt" - "net" "net/url" - "os" - "path" - "strings" "time" "go.opentelemetry.io/otel/attribute" @@ -30,48 +25,29 @@ type OTLPClient interface { Stop(context.Context) (context.Context, error) } -// StartClient uses the Config to setup and start either a gRPC or HTTP client, -// and returns the OTLPClient interface to them. -func StartClient(ctx context.Context, config Config) (context.Context, OTLPClient) { - if !config.IsRecording() { - return ctx, NewNullClient(config) - } - - if config.Protocol != "" && config.Protocol != "grpc" && config.Protocol != "http/protobuf" { - err := fmt.Errorf("invalid protocol setting %q", config.Protocol) - Diag.Error = err.Error() - config.SoftFail(err.Error()) - } - - endpointURL, _ := ParseEndpoint(config) - - var client OTLPClient - if config.Protocol != "grpc" && - (strings.HasPrefix(config.Protocol, "http/") || - endpointURL.Scheme == "http" || - endpointURL.Scheme == "https") { - client = NewHttpClient(config) - } else { - client = NewGrpcClient(config) - } - - ctx, err := client.Start(ctx) - if err != nil { - Diag.Error = err.Error() - config.SoftFail("Failed to start OTLP client: %s", err) - } - - return ctx, client +// OTLPConfig interface defines all of the methods required to configure OTLP clients. +type OTLPConfig interface { + GetTlsConfig() *tls.Config + GetIsRecording() bool + GetEndpoint() *url.URL + GetInsecure() bool + GetTimeout() time.Duration + GetHeaders() map[string]string + GetStartupTime() time.Time + GetVersion() string + GetServiceName() string } // SendSpan connects to the OTLP server, sends the span, and disconnects. -func SendSpan(ctx context.Context, client OTLPClient, config Config, span *tracepb.Span) (context.Context, error) { - if !config.IsRecording() { +func SendSpan(ctx context.Context, client OTLPClient, config OTLPConfig, span *tracepb.Span) (context.Context, error) { + if !config.GetIsRecording() { return ctx, nil } - resourceAttrs, err := resourceAttributes(ctx, config.ServiceName) - config.SoftFailIfErr(err) + resourceAttrs, err := resourceAttributes(ctx, config.GetServiceName()) + if err != nil { + return ctx, err + } rsps := []*tracepb.ResourceSpans{ { @@ -81,7 +57,7 @@ func SendSpan(ctx context.Context, client OTLPClient, config Config, span *trace ScopeSpans: []*tracepb.ScopeSpans{{ Scope: &commonpb.InstrumentationScope{ Name: "github.com/equinix-labs/otel-cli", - Version: config.Version, + Version: config.GetVersion(), Attributes: []*commonpb.KeyValue{}, DroppedAttributesCount: 0, }, @@ -100,111 +76,10 @@ func SendSpan(ctx context.Context, client OTLPClient, config Config, span *trace return ctx, nil } -// ParseEndpoint takes the endpoint or signal endpoint, augments as needed -// (e.g. bare host:port for gRPC) and then parses as a URL. -// https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md#endpoint-urls-for-otlphttp -func ParseEndpoint(config Config) (*url.URL, string) { - var endpoint, source string - var epUrl *url.URL - var err error - - // signal-specific configs get precedence over general endpoint per OTel spec - if config.TracesEndpoint != "" { - endpoint = config.TracesEndpoint - source = "signal" - } else if config.Endpoint != "" { - endpoint = config.Endpoint - source = "general" - } else { - config.SoftFail("no endpoint configuration available") - } - - parts := strings.Split(endpoint, ":") - // bare hostname? can only be grpc, prepend - if len(parts) == 1 { - epUrl, err = url.Parse("grpc://" + endpoint + ":4317") - if err != nil { - config.SoftFail("error parsing (assumed) gRPC bare host address '%s': %s", endpoint, err) - } - } else if len(parts) > 1 { // could be URI or host:port - // actual URIs - // grpc:// is only an otel-cli thing, maybe should drop it? - if parts[0] == "grpc" || parts[0] == "http" || parts[0] == "https" { - epUrl, err = url.Parse(endpoint) - if err != nil { - config.SoftFail("error parsing provided %s URI '%s': %s", source, endpoint, err) - } - } else { - // gRPC host:port - epUrl, err = url.Parse("grpc://" + endpoint) - if err != nil { - config.SoftFail("error parsing (assumed) gRPC host:port address '%s': %s", endpoint, err) - } - } - } - - // Per spec, /v1/traces is the default, appended to any url passed - // to the general endpoint - if strings.HasPrefix(epUrl.Scheme, "http") && source != "signal" && !strings.HasSuffix(epUrl.Path, "/v1/traces") { - epUrl.Path = path.Join(epUrl.Path, "/v1/traces") - } - - Diag.EndpointSource = source - Diag.Endpoint = epUrl.String() - return epUrl, source -} - -// tlsConfig evaluates otel-cli configuration and returns a tls.Config -// that can be used by grpc or https. -func tlsConfig(config Config) *tls.Config { - tlsConfig := &tls.Config{} - - if config.TlsNoVerify { - Diag.InsecureSkipVerify = true - tlsConfig.InsecureSkipVerify = true - } - - // puts the provided CA certificate into the root pool - // when not provided, Go TLS will automatically load the system CA pool - if config.TlsCACert != "" { - data, err := os.ReadFile(config.TlsCACert) - if err != nil { - config.SoftFail("failed to load CA certificate: %s", err) - } - - certpool := x509.NewCertPool() - certpool.AppendCertsFromPEM(data) - tlsConfig.RootCAs = certpool - } - - // client certificate authentication - if config.TlsClientCert != "" && config.TlsClientKey != "" { - clientPEM, err := os.ReadFile(config.TlsClientCert) - if err != nil { - config.SoftFail("failed to read client certificate file %s: %s", config.TlsClientCert, err) - } - clientKeyPEM, err := os.ReadFile(config.TlsClientKey) - if err != nil { - config.SoftFail("failed to read client key file %s: %s", config.TlsClientKey, err) - } - certPair, err := tls.X509KeyPair(clientPEM, clientKeyPEM) - if err != nil { - config.SoftFail("failed to parse client cert pair: %s", err) - } - tlsConfig.Certificates = []tls.Certificate{certPair} - } else if config.TlsClientCert != "" { - config.SoftFail("client cert and key must be specified together") - } else if config.TlsClientKey != "" { - config.SoftFail("client cert and key must be specified together") - } - - return tlsConfig -} - // deadlineCtx sets timeout on the context if the duration is non-zero. // Otherwise it returns the context as-is. -func deadlineCtx(ctx context.Context, config Config, startupTime time.Time) (context.Context, context.CancelFunc) { - if timeout := config.ParseCliTimeout(); timeout > 0 { +func deadlineCtx(ctx context.Context, timeout time.Duration, startupTime time.Time) (context.Context, context.CancelFunc) { + if timeout > 0 { deadline := startupTime.Add(timeout) return context.WithDeadline(ctx, deadline) } @@ -212,43 +87,6 @@ func deadlineCtx(ctx context.Context, config Config, startupTime time.Time) (con return ctx, func() {} } -// isLoopbackAddr takes a url.URL, looks up the address, then returns true -// if it points at either a v4 or v6 loopback address. -// As I understood the OTLP spec, only host:port or an HTTP URL are acceptable. -// This function is _not_ meant to validate the endpoint, that will happen when -// otel-go attempts to connect to the endpoint. -func isLoopbackAddr(u *url.URL) (bool, error) { - hostname := u.Hostname() - - if hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" { - Diag.DetectedLocalhost = true - return true, nil - } - - ips, err := net.LookupIP(hostname) - if err != nil { - return false, fmt.Errorf("unable to look up hostname '%s': %s", hostname, err) - } - - // all ips returned must be loopback to return true - // cases where that isn't true should be super rare, and probably all shenanigans - allAreLoopback := true - for _, ip := range ips { - if !ip.IsLoopback() { - allAreLoopback = false - } - } - - Diag.DetectedLocalhost = allAreLoopback - return allAreLoopback, nil -} - -// isInsecureSchema returns true if the provided endpoint is an unencrypted HTTP URL or unix socket -func isInsecureSchema(endpoint string) bool { - return strings.HasPrefix(endpoint, "http://") || - strings.HasPrefix(endpoint, "unix://") -} - // resourceAttributes calls the OTel SDK to get automatic resource attrs and // returns them converted to []*commonpb.KeyValue for use with protobuf. func resourceAttributes(ctx context.Context, serviceName string) ([]*commonpb.KeyValue, error) { @@ -335,7 +173,7 @@ func SaveError(ctx context.Context, t time.Time, err error) (context.Context, er return ctx, nil } - Diag.SetError(err) // legacy, will go away when Diag is removed + //otelcli.Diag.SetError(err) // legacy, will go away when Diag is removed te := TimestampedError{ Timestamp: t, @@ -364,15 +202,15 @@ func SaveError(ctx context.Context, t time.Time, err error) (context.Context, er // TODO: --otlp-retry-sleep? --otlp-retry-timeout? // TODO: span events? hmm... feels weird to plumb spans this deep into the client // but it's probably fine? -func retry(ctx context.Context, config Config, timeout time.Duration, fun retryFun) (context.Context, error) { - deadline := config.StartupTime.Add(timeout) +func retry(ctx context.Context, config OTLPConfig, fun retryFun) (context.Context, error) { + deadline := config.GetStartupTime().Add(config.GetTimeout()) sleep := time.Duration(0) for { if ctx, keepGoing, wait, err := fun(ctx); err != nil { if err != nil { ctx, _ = SaveError(ctx, time.Now(), err) } - config.SoftLog("error on retry %d: %s", Diag.Retries, err) + //config.SoftLog("error on retry %d: %s", Diag.Retries, err) if keepGoing { if wait > 0 { @@ -399,11 +237,6 @@ func retry(ctx context.Context, config Config, timeout time.Duration, fun retryF } else { return ctx, nil } - - // It's retries instead of "tries" because "tries" means other things - // too. Also, retries can default to 0 and it makes sense, saving - // copying in test data. - Diag.Retries++ } } diff --git a/otlpclient/otlp_client_grpc.go b/otlpclient/otlp_client_grpc.go index 84433ad..b8477dc 100644 --- a/otlpclient/otlp_client_grpc.go +++ b/otlpclient/otlp_client_grpc.go @@ -2,7 +2,7 @@ package otlpclient import ( "context" - "strings" + "fmt" "time" coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1" @@ -20,11 +20,11 @@ import ( type GrpcClient struct { conn *grpc.ClientConn client coltracepb.TraceServiceClient - config Config + config OTLPConfig } // NewGrpcClient returns a fresh GrpcClient ready to Start. -func NewGrpcClient(config Config) *GrpcClient { +func NewGrpcClient(config OTLPConfig) *GrpcClient { c := GrpcClient{config: config} return &c } @@ -32,7 +32,7 @@ func NewGrpcClient(config Config) *GrpcClient { // Start configures and starts the connection to the gRPC server in the background. func (gc *GrpcClient) Start(ctx context.Context) (context.Context, error) { var err error - endpointURL, _ := ParseEndpoint(gc.config) + endpointURL := gc.config.GetEndpoint() host := endpointURL.Hostname() if endpointURL.Port() != "" { host = host + ":" + endpointURL.Port() @@ -40,31 +40,16 @@ func (gc *GrpcClient) Start(ctx context.Context) (context.Context, error) { grpcOpts := []grpc.DialOption{} - // Go's TLS does the right thing and forces us to say we want to disable encryption, - // but I expect most users of this program to point at a localhost endpoint that might not - // have any encryption available, or setting it up raises the bar of entry too high. - // The compromise is to automatically flip this flag to true when endpoint contains an - // an obvious "localhost", "127.0.0.x", or "::1" address. - isLoopback, err := isLoopbackAddr(endpointURL) - gc.config.SoftFailIfErr(err) - if gc.config.Insecure || (isLoopback && !strings.HasPrefix(gc.config.Endpoint, "https")) { + if gc.config.GetInsecure() { grpcOpts = append(grpcOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) - } else if !isInsecureSchema(gc.config.Endpoint) { - grpcOpts = append(grpcOpts, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig(gc.config)))) + } else { + grpcOpts = append(grpcOpts, grpc.WithTransportCredentials(credentials.NewTLS(gc.config.GetTlsConfig()))) } - // OTLP examples usually show this with the grpc.WithBlock() dial option to - // make the connection synchronous, but it's not the right default for cli - // instead, rely on the shutdown methods to make sure everything is flushed - // by the time the program exits. - if gc.config.Blocking { - grpcOpts = append(grpcOpts, grpc.WithBlock()) - } - - ctx, _ = deadlineCtx(ctx, gc.config, gc.config.StartupTime) + ctx, _ = deadlineCtx(ctx, gc.config.GetTimeout(), gc.config.GetStartupTime()) gc.conn, err = grpc.DialContext(ctx, host, grpcOpts...) if err != nil { - gc.config.SoftFail("could not connect to gRPC/OTLP: %s", err) + return ctx, fmt.Errorf("could not connect to gRPC/OTLP: %w", err) } gc.client = coltracepb.NewTraceServiceClient(gc.conn) @@ -77,15 +62,15 @@ func (gc *GrpcClient) Start(ctx context.Context) (context.Context, error) { // TODO: look into grpc.WaitForReady(), esp for status use cases func (gc *GrpcClient) UploadTraces(ctx context.Context, rsps []*tracepb.ResourceSpans) (context.Context, error) { // add headers onto the request - if len(gc.config.Headers) > 0 { - md := metadata.New(gc.config.Headers) + headers := gc.config.GetHeaders() + if len(headers) > 0 { + md := metadata.New(headers) ctx = metadata.NewOutgoingContext(ctx, md) } req := coltracepb.ExportTraceServiceRequest{ResourceSpans: rsps} - timeout := gc.config.ParseCliTimeout() - return retry(ctx, gc.config, timeout, func(innerCtx context.Context) (context.Context, bool, time.Duration, error) { + return retry(ctx, gc.config, func(innerCtx context.Context) (context.Context, bool, time.Duration, error) { etsr, err := gc.client.Export(innerCtx, &req) return processGrpcStatus(innerCtx, etsr, err) }) diff --git a/otlpclient/otlp_client_http.go b/otlpclient/otlp_client_http.go index 200ee58..7b0cb37 100644 --- a/otlpclient/otlp_client_http.go +++ b/otlpclient/otlp_client_http.go @@ -9,7 +9,6 @@ import ( "net" "net/http" "net/url" - "strings" "time" coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1" @@ -20,13 +19,12 @@ import ( // HttpClient holds state information for HTTP/OTLP. type HttpClient struct { - client *http.Client - config Config - timeout time.Duration + client *http.Client + config OTLPConfig } // NewHttpClient returns an initialized HttpClient. -func NewHttpClient(config Config) *HttpClient { +func NewHttpClient(config OTLPConfig) *HttpClient { c := HttpClient{config: config} return &c } @@ -34,25 +32,17 @@ func NewHttpClient(config Config) *HttpClient { // Start sets up the client configuration. // TODO: see if there's a way to background start http2 connections? func (hc *HttpClient) Start(ctx context.Context) (context.Context, error) { - tlsConf := tlsConfig(hc.config) - hc.timeout = hc.config.ParseCliTimeout() - - endpointURL, _ := ParseEndpoint(hc.config) - isLoopback, err := isLoopbackAddr(endpointURL) - hc.config.SoftFailIfErr(err) - if hc.config.Insecure || (isLoopback && !strings.HasPrefix(hc.config.Endpoint, "https")) { - hc.client = &http.Client{Timeout: hc.timeout} - } else if !isInsecureSchema(hc.config.Endpoint) { + if hc.config.GetInsecure() { + hc.client = &http.Client{Timeout: hc.config.GetTimeout()} + } else { hc.client = &http.Client{ - Timeout: hc.timeout, + Timeout: hc.config.GetTimeout(), Transport: &http.Transport{ DialTLS: func(network, addr string) (net.Conn, error) { - return tls.Dial(network, addr, tlsConf) + return tls.Dial(network, addr, hc.config.GetTlsConfig()) }, }, } - } else { - hc.config.SoftFail("BUG in otel-cli: an invalid configuration made it too far. Please report to https://github.com/equinix-labs/otel-cli/issues.") } return ctx, nil } @@ -66,18 +56,18 @@ func (hc *HttpClient) UploadTraces(ctx context.Context, rsps []*tracepb.Resource } body := bytes.NewBuffer(protoMsg) - endpointURL, _ := ParseEndpoint(hc.config) + endpointURL := hc.config.GetEndpoint() req, err := http.NewRequest("POST", endpointURL.String(), body) if err != nil { return ctx, fmt.Errorf("failed to create HTTP POST request: %w", err) } - for k, v := range hc.config.Headers { + for k, v := range hc.config.GetHeaders() { req.Header.Add(k, v) } req.Header.Set("Content-Type", "application/x-protobuf") - return retry(ctx, hc.config, hc.timeout, func(context.Context) (context.Context, bool, time.Duration, error) { + return retry(ctx, hc.config, func(context.Context) (context.Context, bool, time.Duration, error) { var body []byte resp, err := hc.client.Do(req) if uerr, ok := err.(*url.Error); ok { diff --git a/otlpclient/otlp_client_null.go b/otlpclient/otlp_client_null.go index 74a5218..70ff61d 100644 --- a/otlpclient/otlp_client_null.go +++ b/otlpclient/otlp_client_null.go @@ -11,7 +11,7 @@ import ( type NullClient struct{} // NewNullClient returns a fresh NullClient ready to Start. -func NewNullClient(config Config) *NullClient { +func NewNullClient(config OTLPConfig) *NullClient { return &NullClient{} } diff --git a/otlpclient/otlp_client_test.go b/otlpclient/otlp_client_test.go index 4c00434..59b5992 100644 --- a/otlpclient/otlp_client_test.go +++ b/otlpclient/otlp_client_test.go @@ -42,72 +42,3 @@ func TestErrorLists(t *testing.T) { } } - -func TestParseEndpoint(t *testing.T) { - // func parseEndpoint(config Config) (*url.URL, string) { - - for _, tc := range []struct { - config Config - wantEndpoint string - wantSource string - }{ - // gRPC, general, bare host - { - config: DefaultConfig().WithEndpoint("localhost"), - wantEndpoint: "grpc://localhost:4317", - wantSource: "general", - }, - // gRPC, general, should be bare host:port - { - config: DefaultConfig().WithEndpoint("localhost:4317"), - wantEndpoint: "grpc://localhost:4317", - wantSource: "general", - }, - // gRPC, general, https URL, should transform to host:port - { - config: DefaultConfig().WithEndpoint("https://localhost:4317").WithProtocol("grpc"), - wantEndpoint: "https://localhost:4317/v1/traces", - wantSource: "general", - }, - // HTTP, general, with a provided default signal path, should not be modified - { - config: DefaultConfig().WithEndpoint("http://localhost:9999/v1/traces"), - wantEndpoint: "http://localhost:9999/v1/traces", - wantSource: "general", - }, - // HTTP, general, with a provided custom signal path, signal path should get appended - { - config: DefaultConfig().WithEndpoint("http://localhost:9999/my/collector/path"), - wantEndpoint: "http://localhost:9999/my/collector/path/v1/traces", - wantSource: "general", - }, - // HTTPS, general, without path, should get /v1/traces appended - { - config: DefaultConfig().WithEndpoint("https://localhost:4317"), - wantEndpoint: "https://localhost:4317/v1/traces", - wantSource: "general", - }, - // gRPC, signal, should come through with just the grpc:// added - { - config: DefaultConfig().WithTracesEndpoint("localhost"), - wantEndpoint: "grpc://localhost:4317", - wantSource: "signal", - }, - // http, signal, should come through unmodified - { - config: DefaultConfig().WithTracesEndpoint("http://localhost"), - wantEndpoint: "http://localhost", - wantSource: "signal", - }, - } { - u, src := ParseEndpoint(tc.config) - - if u.String() != tc.wantEndpoint { - t.Errorf("Expected endpoint %q but got %q", tc.wantEndpoint, u.String()) - } - - if src != tc.wantSource { - t.Errorf("Expected source %q for test url %q but got %q", tc.wantSource, u.String(), src) - } - } -} diff --git a/otlpclient/protobuf_span.go b/otlpclient/protobuf_span.go index c5793e9..dbbe24e 100644 --- a/otlpclient/protobuf_span.go +++ b/otlpclient/protobuf_span.go @@ -9,8 +9,7 @@ package otlpclient import ( "crypto/rand" "encoding/hex" - "fmt" - "io" + "sort" "strconv" "time" @@ -19,15 +18,15 @@ import ( tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) -var emptyTraceId = []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} -var emptySpanId = []byte{0, 0, 0, 0, 0, 0, 0, 0} +type SpanConfig interface { +} // NewProtobufSpan returns an initialized OpenTelemetry protobuf Span. func NewProtobufSpan() *tracepb.Span { now := time.Now() span := tracepb.Span{ - TraceId: emptyTraceId, - SpanId: emptySpanId, + TraceId: GetEmptyTraceId(), + SpanId: GetEmptySpanId(), TraceState: "", ParentSpanId: []byte{}, Name: "BUG IN OTEL-CLI: unset", @@ -59,114 +58,48 @@ func NewProtobufSpanEvent() *tracepb.Span_Event { } } -// NewProtobufSpanWithConfig creates a new span and populates it with information -// from the provided config struct. -func NewProtobufSpanWithConfig(c Config) *tracepb.Span { - span := NewProtobufSpan() - span.TraceId = generateTraceId(c) - span.SpanId = generateSpanId(c) - span.Name = c.SpanName - span.Kind = SpanKindStringToInt(c.Kind) - span.Attributes = StringMapAttrsToProtobuf(c.Attributes) - - now := time.Now() - if c.SpanStartTime != "" { - st := c.ParsedSpanStartTime() - span.StartTimeUnixNano = uint64(st.UnixNano()) - } else { - span.StartTimeUnixNano = uint64(now.UnixNano()) - } - - if c.SpanEndTime != "" { - et := c.ParsedSpanEndTime() - span.EndTimeUnixNano = uint64(et.UnixNano()) - } else { - span.EndTimeUnixNano = uint64(now.UnixNano()) - } - - if c.IsRecording() { - tp := LoadTraceparent(c, span) - if tp.Initialized { - span.TraceId = tp.TraceId - span.ParentSpanId = tp.SpanId - } - } else { - span.TraceId = emptyTraceId - span.SpanId = emptySpanId - } - - // --force-trace-id, --force-span-id and --force-parent-span-id let the user set their own trace, span & parent span ids - // these work in non-recording mode and will stomp trace id from the traceparent - var err error - if c.ForceTraceId != "" { - span.TraceId, err = parseHex(c.ForceTraceId, 16) - c.SoftFailIfErr(err) - } - if c.ForceSpanId != "" { - span.SpanId, err = parseHex(c.ForceSpanId, 8) - c.SoftFailIfErr(err) - } - if c.ForceParentSpanId != "" { - span.ParentSpanId, err = parseHex(c.ForceParentSpanId, 8) - c.SoftFailIfErr(err) - } - - SetSpanStatus(span, c) - - return span -} - // SetSpanStatus checks for status code error in the config and sets the // span's 2 values as appropriate. // Only set status description when an error status. // https://github.com/open-telemetry/opentelemetry-specification/blob/480a19d702470563d32a870932be5ddae798079c/specification/trace/api.md#set-status -func SetSpanStatus(span *tracepb.Span, c Config) { - statusCode := SpanStatusStringToInt(c.StatusCode) +func SetSpanStatus(span *tracepb.Span, status string, message string) { + statusCode := SpanStatusStringToInt(status) if statusCode != tracepb.Status_STATUS_CODE_UNSET { span.Status.Code = statusCode - span.Status.Message = c.StatusDescription + span.Status.Message = message } } -// generateTraceId generates a random 16 byte trace id -func generateTraceId(c Config) []byte { - if c.IsRecording() { - buf := make([]byte, 16) - _, err := rand.Read(buf) - if err != nil { - c.SoftFail("Failed to generate random data for trace id: %s", err) - } - return buf - } else { - return emptyTraceId - } +// GetEmptyTraceId returns a 16-byte trace id that's all zeroes. +func GetEmptyTraceId() []byte { + return []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} } -// generateSpanId generates a random 8 byte span id -func generateSpanId(c Config) []byte { - if c.IsRecording() { - buf := make([]byte, 8) - _, err := rand.Read(buf) - if err != nil { - c.SoftFail("Failed to generate random data for span id: %s", err) - } - return buf - } else { - return emptySpanId - } +// GetEmptySpanId returns an 8-byte span id that's all zeroes. +func GetEmptySpanId() []byte { + return []byte{0, 0, 0, 0, 0, 0, 0, 0} } -// parseHex parses hex into a []byte of length provided. Errors if the input is -// not valid hex or the converted hex is not the right number of bytes. -func parseHex(in string, expectedLen int) ([]byte, error) { - out, err := hex.DecodeString(in) +// GenerateTraceId generates a random 16 byte trace id +func GenerateTraceId() []byte { + buf := make([]byte, 16) + _, err := rand.Read(buf) if err != nil { - return nil, fmt.Errorf("error parsing hex string %q: %w", in, err) + // should never happen, crash when it does + panic("failed to generate random data for trace id: " + err.Error()) } - if len(out) != expectedLen { - return nil, fmt.Errorf("hex string %q is the wrong length, expected %d bytes but got %d", in, expectedLen, len(out)) + return buf +} + +// GenerateSpanId generates a random 8 byte span id +func GenerateSpanId() []byte { + buf := make([]byte, 8) + _, err := rand.Read(buf) + if err != nil { + // should never happen, crash when it does + panic("failed to generate random data for span id: " + err.Error()) } - return out, nil + return buf } // SpanKindIntToString takes an integer/constant protobuf span kind value @@ -314,74 +247,40 @@ func SpanToStringMap(span *tracepb.Span, rss *tracepb.ResourceSpans) map[string] } // TraceparentFromProtobufSpan builds a Traceparent struct from the provided span. -func TraceparentFromProtobufSpan(c Config, span *tracepb.Span) traceparent.Traceparent { +func TraceparentFromProtobufSpan(span *tracepb.Span, recording bool) traceparent.Traceparent { return traceparent.Traceparent{ Version: 0, TraceId: span.TraceId, SpanId: span.SpanId, - Sampling: c.IsRecording(), + Sampling: recording, Initialized: true, } } -// PropagateTraceparent saves the traceparent to file if necessary, then prints -// span info to the console according to command-line args. -func PropagateTraceparent(c Config, span *tracepb.Span, target io.Writer) { - var tp traceparent.Traceparent - if c.IsRecording() { - tp = TraceparentFromProtobufSpan(c, span) - } else { - // when in non-recording mode, and there is a TP available, propagate that - tp = LoadTraceparent(c, span) - } - - if c.TraceparentCarrierFile != "" { - err := tp.SaveToFile(c.TraceparentCarrierFile, c.TraceparentPrintExport) - c.SoftFailIfErr(err) +// flattenStringMap takes a string map and returns it flattened into a string with +// keys sorted lexically so it should be mostly consistent enough for comparisons +// and printing. Output is k=v,k=v style like attributes input. +func flattenStringMap(mp map[string]string, emptyValue string) string { + if len(mp) == 0 { + return emptyValue } - if c.TraceparentPrint { - tp.Fprint(target, c.TraceparentPrintExport) + var out string + keys := make([]string, len(mp)) // for sorting + var i int + for k := range mp { + keys[i] = k + i++ } -} + sort.Strings(keys) -// LoadTraceparent follows otel-cli's loading rules, start with envvar then file. -// If both are set, the file will override env. -// When in non-recording mode, the previous traceparent will be returned if it's -// available, otherwise, a zero-valued traceparent is returned. -func LoadTraceparent(c Config, span *tracepb.Span) traceparent.Traceparent { - tp := traceparent.Traceparent{ - Version: 0, - TraceId: emptyTraceId, - SpanId: emptySpanId, - Sampling: false, - Initialized: true, - } - - if !c.TraceparentIgnoreEnv { - var err error - tp, err = traceparent.LoadFromEnv() - if err != nil { - Diag.Error = err.Error() + for i, k := range keys { + out = out + k + "=" + mp[k] + if i == len(keys)-1 { + break } + out = out + "," } - if c.TraceparentCarrierFile != "" { - fileTp, err := traceparent.LoadFromFile(c.TraceparentCarrierFile) - if err != nil { - Diag.Error = err.Error() - } else if fileTp.Initialized { - tp = fileTp - } - } - - if c.TraceparentRequired { - if tp.Initialized { - return tp - } else { - c.SoftFail("failed to find a valid traceparent carrier in either environment for file '%s' while it's required by --tp-required", c.TraceparentCarrierFile) - } - } - - return tp + return out } diff --git a/otlpclient/protobuf_span_test.go b/otlpclient/protobuf_span_test.go index ea2ab0b..b207976 100644 --- a/otlpclient/protobuf_span_test.go +++ b/otlpclient/protobuf_span_test.go @@ -2,9 +2,6 @@ package otlpclient import ( "bytes" - "encoding/hex" - "fmt" - "os" "strconv" "testing" @@ -46,27 +43,11 @@ func TestNewProtobufSpanEvent(t *testing.T) { } } -func TestNewProtobufSpanWithConfig(t *testing.T) { - c := DefaultConfig().WithSpanName("test span 123") - span := NewProtobufSpanWithConfig(c) - - if span.Name != "test span 123" { - t.Error("span event attributes must not be nil") - } -} - func TestGenerateTraceId(t *testing.T) { - c := DefaultConfig() - // non-recording - tid := generateTraceId(c) + tid := GenerateTraceId() - if !bytes.Equal(tid, emptyTraceId) { - t.Error("generated trace id must always be zeroes in non-recording mode") - } - - tid = generateTraceId(c.WithEndpoint("localhost:4317")) - if bytes.Equal(tid, emptyTraceId) { - t.Error("generated trace id must not be zeroes in recording mode") + if bytes.Equal(tid, GetEmptyTraceId()) { + t.Error("generated trace id is all zeroes and should be any other random value") } if len(tid) != 16 { @@ -75,17 +56,10 @@ func TestGenerateTraceId(t *testing.T) { } func TestGenerateSpanId(t *testing.T) { - c := DefaultConfig() - // non-recording - sid := generateSpanId(c) + sid := GenerateSpanId() - if !bytes.Equal(sid, emptySpanId) { - t.Error("generated span id must always be zeroes in non-recording mode") - } - - sid = generateSpanId(c.WithEndpoint("localhost:4317")) - if bytes.Equal(sid, emptySpanId) { - t.Error("generated span id must not be zeroes in recording mode") + if bytes.Equal(sid, GetEmptySpanId()) { + t.Error("generated span id is all zeroes and should be any other random value") } if len(sid) != 8 { @@ -257,37 +231,3 @@ func TestCliAttrsToOtel(t *testing.T) { } } } - -func TestPropagateTraceparent(t *testing.T) { - config := DefaultConfig(). - WithTraceparentCarrierFile(""). - WithTraceparentPrint(false). - WithTraceparentPrintExport(false) - - tp := "00-3433d5ae39bdfee397f44be5146867b3-8a5518f1e5c54d0a-01" - tid := "3433d5ae39bdfee397f44be5146867b3" - sid := "8a5518f1e5c54d0a" - os.Setenv("TRACEPARENT", tp) - - span := NewProtobufSpan() - span.TraceId, _ = hex.DecodeString(tid) - span.SpanId, _ = hex.DecodeString(sid) - - buf := new(bytes.Buffer) - PropagateTraceparent(config, span, buf) - if buf.Len() != 0 { - t.Errorf("nothing was supposed to be written but %d bytes were", buf.Len()) - } - - config.TraceparentPrint = true - config.TraceparentPrintExport = true - buf = new(bytes.Buffer) - PropagateTraceparent(config, span, buf) - if buf.Len() == 0 { - t.Error("expected more than zero bytes but got none") - } - expected := fmt.Sprintf("# trace id: %s\n# span id: %s\nexport TRACEPARENT=%s\n", tid, sid, tp) - if buf.String() != expected { - t.Errorf("got unexpected output, expected '%s', got '%s'", expected, buf.String()) - } -}