diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ea3f4f2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + - name: Setup + uses: actions/setup-go@v2 + with: + go-version: 1.16 + - name: Build otelinit + run: go build -v ./... + - name: Build test stub + working-directory: ./cmd/test-otel-init-go + run: go build -v + - name: Install otel-cli for tests + uses: engineerd/[email protected] + with: + name: "otel-cli" + pathInArchive: /otel-cli + url: "https://github.com/equinix-labs/otel-cli/releases/download/v0.0.5/otel-cli-0.0.5-Linux-x86_64.tar.gz" + - name: Test + run: go test -v ./... diff --git a/.gitignore b/.gitignore index 66fd13c..cfe057f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,5 +11,4 @@ # Output of the go coverage tool, specifically when used with LiteIDE *.out -# Dependency directories (remove the comment below to include it) -# vendor/ +cmd/test-otel-init-go/test-otel-init-go diff --git a/README.md b/README.md index d1ebd5a..ffbeeaa 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ import ( func main() { ctx := context.Background() - otelShutdown := otelinit.InitOpenTelemetry(ctx, "my-amazing-application") + ctx, otelShutdown := otelinit.InitOpenTelemetry(ctx, "my-amazing-application") defer otelShutdown(ctx) } ``` diff --git a/cmd/test-otel-init-go/main.go b/cmd/test-otel-init-go/main.go new file mode 100644 index 0000000..0097820 --- /dev/null +++ b/cmd/test-otel-init-go/main.go @@ -0,0 +1,73 @@ +package main + +// All this program does is load up otel-init-go, create one trace, dump state +// in json for all these things, then exit. This data is intended to be consumed +// in main_test.go, which is really about testing otel-cli-init itself. + +import ( + "context" + "encoding/json" + "log" + "os" + "strconv" + "strings" + + "github.com/equinix-labs/otel-init-go/otelinit" + "go.opentelemetry.io/otel" +) + +func main() { + ctx := context.Background() + ctx, otelShutdown := otelinit.InitOpenTelemetry(ctx, "otel-init-go-test") + defer otelShutdown(ctx) + + tracer := otel.Tracer("otel-init-go-test") + ctx, span := tracer.Start(ctx, "dump state") + + env := make(map[string]string) + for _, e := range os.Environ() { + parts := strings.SplitN(e, "=", 2) + if len(parts) == 2 { + // it's not great to hard-code a specific envvar here but this is + // probably the most dangerous one we don't want to blithely print + // here. open to suggestions... + if !strings.HasPrefix(parts[0], "GITHUB") { + env[parts[0]] = parts[1] + } + } else { + log.Fatalf("BUG this shouldn't happen") + } + } + + // public.go stuffs the config in the context just so we can do this + conf, ok := otelinit.ConfigFromContext(ctx) + if !ok { + log.Println("failed to retrieve otelinit.Config pointer from context, test results may be invalid") + conf = &otelinit.Config{} + } + sc := span.SpanContext() + outData := map[string]map[string]string{ + "config": { + "endpoint": conf.Endpoint, + "service_name": conf.Servicename, + "insecure": strconv.FormatBool(conf.Insecure), + }, + "otel": { + "trace_id": sc.TraceID().String(), + "span_id": sc.SpanID().String(), + "trace_flags": sc.TraceFlags().String(), + "is_sampled": strconv.FormatBool(sc.IsSampled()), + }, + "env": env, + } + + js, err := json.MarshalIndent(outData, "", " ") + if err != nil { + log.Fatal(err) + } + + os.Stdout.Write(js) + os.Stdout.WriteString("\n") + + span.End() +} diff --git a/cmd/test-otel-init-go/main_test.go b/cmd/test-otel-init-go/main_test.go new file mode 100644 index 0000000..686bb41 --- /dev/null +++ b/cmd/test-otel-init-go/main_test.go @@ -0,0 +1,291 @@ +package main + +// testing test-otel-init-go tests otel-init-go by using otel-cli +// to receive spans and validate things are working +// +// this is still very much a work in progress idea and might not fully +// pan out but the first part is looking good +// +// TODOs: +// [ ] replace that time.Sleep with proper synchronization +// [ ] use random ports for listener address? + +import ( + "encoding/json" + "fmt" + "io/fs" + "io/ioutil" + "log" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" +) + +// CliEvent is mostly the same as otel-cli's internal event format, with +// the addition that it has a place to stuff events. +type CliEvent struct { + TraceID string `json:"trace_id"` + SpanID string `json:"span_id"` + ParentID string `json:"parent_span_id"` + Library string `json:"library"` + Name string `json:"name"` + Kind string `json:"kind"` + Start string `json:"start"` + End string `json:"end"` + ElapsedMS int `json:"elapsed_ms"` + Attributes map[string]string `json:"attributes"` + Events []CliEvent // reader code will stuff kind=event in here +} + +// tid sid +type CliEvents map[string]map[string]CliEvent + +// StubData is the structure of the data that the stub program +// prints out. +type StubSpan map[string]string +type StubData struct { + Config map[string]string `json:"config"` + Env map[string]string `json:"env"` + Otel StubSpan `json:"otel"` +} + +// Scenario represents the configuration of a test scenario. Scenarios +// are found in json files in this directory. +type Scenario struct { + Name string `json:"name"` + Filename string `json:"-"` + StubEnv map[string]string `json:"stub_env"` // given to stub + StubData StubData `json:"stub_data"` // data from stub, exact match + SpansExpected int `json:"spans_expected"` + Timeout int `json:"timeout"` + ShouldTimeout bool `json:"should_timeout"` // otel connection stub->cli should fail + SkipOtelCli bool `json:"skip_otel_cli"` // don't run otel-cli at all +} + +// needs to be discovered right at startup before env is cleared +var otelCliPath, testStubPath string + +func TestMain(m *testing.M) { + // find otel-cli in PATH before clearing the environment + var err error + otelCliPath, err = exec.LookPath("otel-cli") + if err != nil { + log.Fatalf("cannot run tests: otel-cli must be in PATH: %s", err) + } + + // it is expected that the stub binary has already been built and CI does this + wd, _ := os.Getwd() + testStubPath = filepath.Join(wd, "test-otel-init-go") + + // wipe out this process's envvars right away to avoid pollution & leakage + os.Clearenv() + os.Exit(m.Run()) +} + +// TestOtelInit loads all the json files in this directory and executes the +// tests they define. +func TestOtelInit(t *testing.T) { + // get a list of all json fixtures in the testdata directory + // https://dave.cheney.net/2016/05/10/test-fixtures-in-go + wd, _ := os.Getwd() + files, err := ioutil.ReadDir(filepath.Join(wd, "testdata")) + if err != nil { + t.Fatalf("Failed to list test directory %q to detect json files.", wd) + } + + scenarios := []Scenario{} + for _, file := range files { + if strings.HasSuffix(file.Name(), ".json") { + scenario := Scenario{StubEnv: map[string]string{}} + fp := filepath.Join("testdata", file.Name()) + js, err := os.ReadFile(fp) + if err != nil { + t.Fatalf("Failed to read json test file %q: %s", file.Name(), err) + } + err = json.Unmarshal(js, &scenario) + if err != nil { + t.Fatalf("Failed to parse json test file %q: %s", file.Name(), err) + } + scenario.Filename = filepath.Base(file.Name()) // for error reporting + scenarios = append(scenarios, scenario) + } + } + + t.Logf("Loaded %d tests.", len(scenarios)) + if len(scenarios) == 0 { + t.Fatal("no test fixtures loaded!") + } + + // run all the scenarios, check the results + for _, s := range scenarios { + stubData, events := runPrograms(t, s) + checkData(t, s, stubData, events) + } +} + +// checkData takes the data returned from the stub and compares it to the +// preset data in the scenario and fails the tests if anything doesn't match. +func checkData(t *testing.T, scenario Scenario, stubData StubData, events CliEvents) { + // check the env + if diff := cmp.Diff(scenario.StubData.Env, stubData.Env); diff != "" { + t.Errorf("env data did not match fixture in %q (-want +got):\n%s", scenario.Filename, diff) + } + + // check the otel-init-go config + if diff := cmp.Diff(scenario.StubData.Config, stubData.Config); diff != "" { + t.Errorf("config data did not match fixture in %q (-want +got):\n%s", scenario.Filename, diff) + } + + // check the otel span values + // find usages of *, do the check on the stub data manually, and set up cmpSpan + scSpan := map[string]string{} // to be passed to cmp.Diff + cmpSpan := map[string]string{} // to be passed to cmp.Diff + for what, re := range map[string]*regexp.Regexp{ + "trace_id": regexp.MustCompile("^[0-9a-fA-F]{32}$"), + "span_id": regexp.MustCompile("^[0-9a-fA-F]{16}$"), + "is_sampled": regexp.MustCompile("^true|false$"), + "trace_flags": regexp.MustCompile("^[0-9]{2}$"), + } { + if cv, ok := scenario.StubData.Otel[what]; ok { + scSpan[what] = cv // make a straight copy to make cmp.Diff happy + if sv, ok := stubData.Otel[what]; ok { + cmpSpan[what] = sv // default to the existing value + if cv == "*" { + if re.MatchString(sv) { + cmpSpan[what] = "*" // success!, make the Cmp test succeed + } else { + t.Errorf("stub span value %q for key %s is not valid", sv, what) + } + } + } + } + } + + // do a diff on a generated map that sets values to * when the * check succeeded + if diff := cmp.Diff(scSpan, cmpSpan); diff != "" { + t.Errorf("otel data did not match fixture in %q (-want +got):\n%s", scenario.Filename, diff) + } +} + +// runPrograms runs the stub program and otel-cli together and captures their +// output as data to return for further testing. +// all failures are fatal, no point in testing if this is broken +func runPrograms(t *testing.T, scenario Scenario) (StubData, CliEvents) { + tmpdir, err := os.MkdirTemp(os.TempDir(), "otel-init-go-test") + defer os.RemoveAll(tmpdir) + if err != nil { + t.Fatalf("MkdirTemp failed: %s", err) + } + + cliArgs := []string{"server", "json", "--dir", tmpdir} + + if scenario.Timeout > 0 { + cliArgs = append(cliArgs, "--timeout", strconv.Itoa(scenario.Timeout)) + } + + if scenario.SpansExpected > 0 { + cliArgs = append(cliArgs, "--max-spans", strconv.Itoa(scenario.SpansExpected)) + } + + // MAYBE: server json --stdout is maybe better? and could add a graceful exit on closed fds + otelcli := exec.Command(otelCliPath, cliArgs...) + otelcli.Env = []string{"PATH=/bin"} // apparently this is required for 'getent', no idea why + + if !scenario.SkipOtelCli { + go func() { + err, output := otelcli.CombinedOutput() + if err != nil { + log.Println(output) + log.Fatalf("Executing command %q failed: %s", otelcli.String(), err) + } + }() + } + + // TODO: replace this with something more reliable + // the problem here is we need to wait for otel-cli to start and listen + // so the solution is probably to do some kind of healthcheck on otel-cli's port + // but this works ok for now + time.Sleep(time.Millisecond * 10) + + // run the stub with the scenario's environment + stub := exec.Command(testStubPath) + stub.Env = mkEnviron(scenario.StubEnv) + stubOut, err := stub.Output() + if err != nil { + t.Fatalf("Executing stub command %q failed: %s", stub.String(), err) + } + + stubData := StubData{ + Config: map[string]string{}, + Env: map[string]string{}, + Otel: map[string]string{}, + } + err = json.Unmarshal(stubOut, &stubData) + if err != nil { + fmt.Printf("\n\n%s\n\n", string(stubOut)) + t.Fatalf("Unmarshaling stub output failed: %s", err) + } + + if !scenario.SkipOtelCli { + otelcli.Wait() + } + + events := make(CliEvents) + filepath.WalkDir(tmpdir, func(path string, d fs.DirEntry, err error) error { + // TODO: make sure to read span.json before events.json + // so maybe a directory walk would be better anyways + if strings.HasSuffix(path, ".json") { + pi := strings.Split(path, string(os.PathSeparator)) + if len(pi) >= 3 { + js, err := ioutil.ReadFile(path) + if err != nil { + t.Fatalf("error while reading file %q: %s", path, err) + } + + evt := CliEvent{ + Attributes: make(map[string]string), + Events: make([]CliEvent, 0), + } + err = json.Unmarshal(js, &evt) + if err != nil { + t.Fatalf("error while parsing json file %q: %s", path, err) + } + + tid := pi[len(pi)-3] + sid := pi[len(pi)-2] + if trace, ok := events[tid]; ok { + if _, ok := trace[sid]; ok { + t.Fatal("unfinished code path") + } + trace[sid] = evt + } else { + events[tid] = make(map[string]CliEvent) + events[tid][sid] = evt + } + // TODO: events + } + } + return nil + }) + + return stubData, events +} + +// mkEnviron converts a string map to a list of k=v strings. +func mkEnviron(env map[string]string) []string { + mapped := make([]string, len(env)) + var i int + for k, v := range env { + mapped[i] = k + "=" + v + i++ + } + + return mapped +} diff --git a/cmd/test-otel-init-go/testdata/00-unconfigured.json b/cmd/test-otel-init-go/testdata/00-unconfigured.json new file mode 100644 index 0000000..fd1ba66 --- /dev/null +++ b/cmd/test-otel-init-go/testdata/00-unconfigured.json @@ -0,0 +1,22 @@ +{ + "name": "no configuration at all", + "stub_env": {}, + "stub_data": { + "config": { + "endpoint": "", + "insecure": "false", + "service_name": "otel-init-go-test" + }, + "env": {}, + "otel": { + "is_sampled": "false", + "span_id": "0000000000000000", + "trace_flags": "00", + "trace_id": "00000000000000000000000000000000" + } + }, + "spans_expected": 0, + "timeout": 0, + "should_timeout": false, + "skip_otel_cli": true +} \ No newline at end of file diff --git a/cmd/test-otel-init-go/testdata/01-irrelevant-envvar.json b/cmd/test-otel-init-go/testdata/01-irrelevant-envvar.json new file mode 100644 index 0000000..2d18156 --- /dev/null +++ b/cmd/test-otel-init-go/testdata/01-irrelevant-envvar.json @@ -0,0 +1,26 @@ +{ + "name": "one unrelated envvar", + "stub_env": { + "UNRELATED_ENVVAR": "unrelated data" + }, + "stub_data": { + "config": { + "endpoint": "", + "insecure": "false", + "service_name": "otel-init-go-test" + }, + "env": { + "UNRELATED_ENVVAR": "unrelated data" + }, + "otel": { + "is_sampled": "false", + "span_id": "0000000000000000", + "trace_flags": "00", + "trace_id": "00000000000000000000000000000000" + } + }, + "spans_expected": 0, + "timeout": 0, + "should_timeout": false, + "skip_otel_cli": true +} diff --git a/cmd/test-otel-init-go/testdata/10-local-server-no-tls.json b/cmd/test-otel-init-go/testdata/10-local-server-no-tls.json new file mode 100644 index 0000000..3ad2288 --- /dev/null +++ b/cmd/test-otel-init-go/testdata/10-local-server-no-tls.json @@ -0,0 +1,28 @@ +{ + "name": "local server without tls", + "stub_env": { + "OTEL_EXPORTER_OTLP_ENDPOINT": "localhost:4317", + "OTEL_EXPORTER_OTLP_INSECURE": "true" + }, + "stub_data": { + "config": { + "endpoint": "localhost:4317", + "insecure": "true", + "service_name": "otel-init-go-test" + }, + "env": { + "OTEL_EXPORTER_OTLP_ENDPOINT": "localhost:4317", + "OTEL_EXPORTER_OTLP_INSECURE": "true" + }, + "otel": { + "is_sampled": "true", + "span_id": "*", + "trace_flags": "01", + "trace_id": "*" + } + }, + "spans_expected": 1, + "timeout": 1, + "should_timeout": false, + "skip_otel_cli": false +} diff --git a/go.mod b/go.mod index 4333e75..09dcc00 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/equinix-labs/otel-init-go go 1.15 require ( + github.com/google/go-cmp v0.5.6 // indirect go.opentelemetry.io/otel v1.0.0-RC2 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.0.0-RC2 go.opentelemetry.io/otel/sdk v1.0.0-RC2 diff --git a/otelinit/config.go b/otelinit/config.go index 58f9fbc..85ab45a 100644 --- a/otelinit/config.go +++ b/otelinit/config.go @@ -6,28 +6,37 @@ import ( "strconv" ) -// config holds the typed values of configuration read from environment variables -type config struct { - servicename string - endpoint string - insecure bool +// Config holds the typed values of configuration read from the environment. +// It is public mainly to make testing easier and most users should never +// use it directly. +type Config struct { + Servicename string `json:"service_name"` + Endpoint string `json:"endpoint"` + Insecure bool `json:"insecure"` } // newConfig reads all of the documented environment variables and returns a // config struct. -func newConfig(serviceName string) config { +func newConfig(serviceName string) Config { // Use stdlib to parse. If it's an invalid value and doesn't parse, log it // and keep going. It should already be false on error but we force it to // be extra clear that it's failing closed. - insecure, err := strconv.ParseBool(os.Getenv("OTEL_EXPORTER_OTLP_INSECURE")) - if err != nil { + isEnv := os.Getenv("OTEL_EXPORTER_OTLP_INSECURE") + var insecure bool + if isEnv != "" { + var err error + insecure, err = strconv.ParseBool(isEnv) + if err != nil { + insecure = false + log.Println("Invalid boolean value in OTEL_EXPORTER_OTLP_INSECURE. Try true or false.") + } + } else { insecure = false - log.Println("Invalid boolean value in OTEL_EXPORTER_OTLP_INSECURE. Try true or false.") } - return config{ - servicename: serviceName, - endpoint: os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"), - insecure: insecure, + return Config{ + Servicename: serviceName, + Endpoint: os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"), + Insecure: insecure, } } diff --git a/otelinit/config_test.go b/otelinit/config_test.go new file mode 100644 index 0000000..a90067a --- /dev/null +++ b/otelinit/config_test.go @@ -0,0 +1,95 @@ +package otelinit + +import ( + "os" + "testing" + + "github.com/google/go-cmp/cmp" +) + +var testServiceName = "unitTestService" + +func TestNewConfig(t *testing.T) { + tests := map[string]struct { + envIn map[string]string + wantConfig Config + }{ + "empty env gets empty config": { + envIn: map[string]string{}, + wantConfig: Config{ + Servicename: testServiceName, + }, + }, + "irrelevant envvar changes nothing": { + envIn: map[string]string{ + "OTEL_SOMETHING_SOMETHING": "this should impact nothing", + }, + wantConfig: Config{ + Servicename: testServiceName, + }, + }, + "insecure false stays false": { + envIn: map[string]string{ + "OTEL_EXPORTER_OTLP_INSECURE": "false", + }, + wantConfig: Config{ + Servicename: testServiceName, + Insecure: false, + }, + }, + "insecure true configs true": { + envIn: map[string]string{ + "OTEL_EXPORTER_OTLP_INSECURE": "true", + }, + wantConfig: Config{ + Servicename: testServiceName, + Insecure: true, + }, + }, + // this is by far the most common configuration expected + "otlp endpoint and insecure": { + envIn: map[string]string{ + "OTEL_EXPORTER_OTLP_ENDPOINT": "localhost:4317", + "OTEL_EXPORTER_OTLP_INSECURE": "true", + }, + wantConfig: Config{ + Servicename: testServiceName, + Insecure: true, + Endpoint: "localhost:4317", + }, + }, + // TODO: maybe should NOT do this, and have newConfig() check + // incoming values and ignore obviously bad ones + "otlp endpoint allows arbitrary value": { + envIn: map[string]string{ + "OTEL_EXPORTER_OTLP_ENDPOINT": "asdf asdf asdf", + }, + wantConfig: Config{ + Servicename: testServiceName, + Insecure: false, + Endpoint: "asdf asdf asdf", + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // wipes the whole process's env every test run + os.Clearenv() + // set up the test envvars + for k, v := range tc.envIn { + err := os.Setenv(k, v) + if err != nil { + t.Fatalf("could not set test environment: %s", err) + } + } + // generate a config + c := newConfig(testServiceName) + // see if it's any good + if diff := cmp.Diff(c, tc.wantConfig); diff != "" { + t.Errorf(diff) + } + }) + } + +} diff --git a/otelinit/public.go b/otelinit/public.go index 2def824..33271db 100644 --- a/otelinit/public.go +++ b/otelinit/public.go @@ -2,26 +2,49 @@ package otelinit import "context" +// OtelShutdown is a function that should be called with context +// when you want to shut down OpenTelemetry, usually as a defer +// in main. type OtelShutdown func(context.Context) // InitOpenTelemetry sets up the OpenTelemetry plumbing so it's ready to use. // It requires a context.Context and service name string that is the name of // your service or application. // TODO: should even this be overrideable via envvars? -// Returns a func() that encapuslates clean shutdown. -func InitOpenTelemetry(ctx context.Context, serviceName string) OtelShutdown { +// Returns context and a func() that encapuslates clean shutdown. +func InitOpenTelemetry(ctx context.Context, serviceName string) (context.Context, OtelShutdown) { c := newConfig(serviceName) - if c.endpoint != "" { - tracingShutdown := c.initTracing(ctx) + // no idea if this is gonna work... + // or even if this is a good idea but it would be well out of most folks' + // way here and I can snag it from test code without burdening anyone else + // and it's a teensy amount of memory + ctx = context.WithValue(ctx, "otel-init-config", &c) + + if c.Endpoint != "" { + ctx, tracingShutdown := c.initTracing(ctx) // TODO: initMetrics() // TODO: initLogs() - return func(ctx context.Context) { + return ctx, func(ctx context.Context) { tracingShutdown(ctx) } } // no configuration, nothing to do, the calling code is inert - return func(context.Context) {} + // config is available in the returned context (for test/debug) + return ctx, func(context.Context) {} +} + +// ConfigFromContext extracts the Config struct from the provided context. +// Returns the Config and true if it was retried successfully, false otherwise. +func ConfigFromContext(ctx context.Context) (*Config, bool) { + raw := ctx.Value("otel-init-config") + if raw != nil { + if conf, ok := raw.(*Config); ok { + return conf, true + } + } + + return &Config{}, false } diff --git a/otelinit/tracing.go b/otelinit/tracing.go index b4c0a2d..cdd9a8e 100644 --- a/otelinit/tracing.go +++ b/otelinit/tracing.go @@ -13,16 +13,16 @@ import ( "google.golang.org/grpc/credentials" ) -func (c config) initTracing(ctx context.Context) OtelShutdown { +func (c Config) initTracing(ctx context.Context) (context.Context, OtelShutdown) { // set the service name that will show up in tracing UIs - resAttrs := resource.WithAttributes(semconv.ServiceNameKey.String(c.servicename)) + resAttrs := resource.WithAttributes(semconv.ServiceNameKey.String(c.Servicename)) res, err := resource.New(ctx, resAttrs) if err != nil { log.Fatalf("failed to create OpenTelemetry service name resource: %s", err) } - grpcOpts := []otlpgrpc.Option{otlpgrpc.WithEndpoint(c.endpoint)} - if c.insecure { + grpcOpts := []otlpgrpc.Option{otlpgrpc.WithEndpoint(c.Endpoint)} + if c.Insecure { grpcOpts = append(grpcOpts, otlpgrpc.WithInsecure()) } else { creds := credentials.NewClientTLSFromCert(nil, "") @@ -50,7 +50,7 @@ func (c config) initTracing(ctx context.Context) OtelShutdown { otel.SetTracerProvider(tracerProvider) // the public function will wrap this in its own shutdown function - return func(ctx context.Context) { + return ctx, func(ctx context.Context) { err = tracerProvider.Shutdown(ctx) if err != nil { log.Printf("shutdown of OpenTelemetry tracerProvider failed: %s", err)