diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..97fe952 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[Makefile] +indent_style = tab +tab_width = 4 + +[*.go] +indent_style = tab +tab_width = 4 + diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..043238c --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +@remychantenay diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..48d3ce7 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,39 @@ +name: Main CI + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Install golangci-lint + run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.54.2 + + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: Linting + run: make lint + + - name: govulncheck + run: govulncheck ./... + + - name: Testing + run: make test + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v3 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53d5582 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +vendor/ +.DS_Store +.idea +.vscode diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..9e4aae1 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,10 @@ +linters: + disable-all: false + +linters-settings: + goimports: + local-prefixes: github.com/remychantenay/otel-tag + +run: + timeout: 10m + modules-download-mode: readonly diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..b1f0e60 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +default: test + +.PHONY: lint +lint: + golangci-lint run ./... + +.PHONY: test +test: + go test -race -count=1 ./... + +.PHONY: bench +bench: + go test -bench=. -benchtime=100x diff --git a/README.md b/README.md new file mode 100644 index 0000000..3cf4a73 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# otel-tag +Simple package that extracts OpenTelemetry span attributes from a struct based on tags. + +## Shortcomings +- Do not support embedded structs. + +## License +Apache License Version 2.0 + +TODO +- Shit! What about attribute name prefix???! Init? +- Remove t.Log() calls in tests +- Complete README.md with example diff --git a/baggage.go b/baggage.go new file mode 100644 index 0000000..274e97c --- /dev/null +++ b/baggage.go @@ -0,0 +1,49 @@ +package oteltag + +import ( + "reflect" + "strings" + + "go.opentelemetry.io/otel/baggage" + + "github.com/remychantenay/otel-tag/internal" +) + +// BaggageMembers takes in a struct and spits out OpenTelemetry baggage members +// based on the struct tags. +func BaggageMembers(res any) []baggage.Member { + structValue := reflect.ValueOf(res) + structType := structValue.Type() + fieldCount := structValue.NumField() + + if fieldCount == 0 { + return nil + } + + members := make([]baggage.Member, 0, fieldCount) + for i := 0; i < fieldCount; i++ { + tag := internal.ExtractTag(structValue, i) + + if len(tag) == 0 || tag == valIgnore { + continue + } + + var omitEmpty bool + before, after, found := strings.Cut(tag, ",") + if found { + tag = before + if after == valOmitEmpty { + omitEmpty = true + } + } + + field, value := structType.Field(i), structValue.Field(i) + + member, zeroValue := internal.BaggageMember(field, value, tag) + if !zeroValue || !omitEmpty { + members = append(members, member) + } + } + + return members +} diff --git a/baggage_test.go b/baggage_test.go new file mode 100644 index 0000000..5ffdb6b --- /dev/null +++ b/baggage_test.go @@ -0,0 +1,85 @@ +package oteltag_test + +import ( + "context" + "testing" + + "go.opentelemetry.io/otel/baggage" + + oteltag "github.com/remychantenay/otel-tag" +) + +func TestBaggageMembers(t *testing.T) { + t.Run("when non-zero values - should add all members to baggage", func(t *testing.T) { + const wantMemberCount = 12 + + m := model{ + ValStr: "a_string", + ValInt: 42, + ValInt64: 42000000000, + ValFloat32: 3.14, + ValFloat64: 99.718281828, + ValBool: true, + ValStrSlice: []string{"a_string_1", "a_string_2", "a_string_3"}, + ValIntSlice: []int{1, 2, 3}, + ValInt64Slice: []int64{100000, 200000, 300000}, + ValFloat32Slice: []float32{1.1, 2.2, 3.3}, + ValFloat64Slice: []float64{4.4, 5.5, 6.6}, + ValBoolSlice: []bool{true, false, true, false}, + } + + members := oteltag.BaggageMembers(m) + bag, _ := baggage.New(members...) + ctx := baggage.ContextWithBaggage(context.Background(), bag) + bag = baggage.FromContext(ctx) + + memberCount := len(bag.Members()) + if memberCount != wantMemberCount { + t.Errorf("\ngot %d members\nwant %d", memberCount, wantMemberCount) + } + + for i := range bag.Members() { + t.Log(bag.Members()[i].Key()) + } + }) + + t.Run("when zero values and omitted - should not add members to baggage", func(t *testing.T) { + const wantMemberCount = 1 + + m := model{ValBool: true} + + members := oteltag.BaggageMembers(m) + bag, _ := baggage.New(members...) + ctx := baggage.ContextWithBaggage(context.Background(), bag) + bag = baggage.FromContext(ctx) + + memberCount := len(bag.Members()) + if memberCount != wantMemberCount { + t.Errorf("\ngot %d members\nwant %d", memberCount, wantMemberCount) + } + }) + + t.Run("when zero values and not omitted - should add members to baggage", func(t *testing.T) { + const wantMemberCount = 1 + + m := struct { + ValStrNotOmitted string `otel:"val_str_not_omitted"` + }{ + ValStrNotOmitted: "", + } + + members := oteltag.BaggageMembers(m) + bag, _ := baggage.New(members...) + ctx := baggage.ContextWithBaggage(context.Background(), bag) + bag = baggage.FromContext(ctx) + + memberCount := len(bag.Members()) + if memberCount != wantMemberCount { + t.Errorf("\ngot %d members\nwant %d", memberCount, wantMemberCount) + } + + for i := range bag.Members() { + t.Log(bag.Members()[i].Key()) + } + }) +} diff --git a/benchmark_test.go b/benchmark_test.go new file mode 100644 index 0000000..0c8d49c --- /dev/null +++ b/benchmark_test.go @@ -0,0 +1,87 @@ +package oteltag_test + +import ( + "context" + "testing" + + oteltag "github.com/remychantenay/otel-tag" + "go.opentelemetry.io/otel/attribute" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "go.opentelemetry.io/otel/trace" +) + +var ( + traceExporter *tracetest.InMemoryExporter + tracer trace.Tracer +) + +func init() { + traceExporter, tracer = setUpBenchmarkTracer() +} + +func setUpBenchmarkTracer() (*tracetest.InMemoryExporter, trace.Tracer) { + exporter := tracetest.NewInMemoryExporter() + traceProvider := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter)) + tracer := traceProvider.Tracer("benchmark-tracer") + + return exporter, tracer +} + +func BenchmarkSpanAttributes_With(b *testing.B) { + traceExporter.Reset() + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + m := model{ + ValStr: "a_string", + ValInt: 42, + ValInt64: 42000000000, + ValFloat32: 3.14, + ValFloat64: 99.718281828, + ValBool: true, + ValStrSlice: []string{"a_string_1", "a_string_2", "a_string_3"}, + ValIntSlice: []int{1, 2, 3}, + ValInt64Slice: []int64{100000, 200000, 300000}, + ValFloat32Slice: []float32{1.1, 2.2, 3.3}, + ValFloat64Slice: []float64{4.4, 5.5, 6.6}, + ValBoolSlice: []bool{true, false, true, false}, + } + + _, span := tracer.Start( + context.Background(), "operation-name", + trace.WithAttributes(oteltag.SpanAttributes(m)...), + ) + defer span.End() + } + }) +} + +func BenchmarkSpanAttributes_Without(b *testing.B) { + traceExporter.Reset() + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + _, span := tracer.Start( + context.Background(), "operation-name", + trace.WithAttributes( + attribute.String("val_str", "a_string"), + attribute.Int("val_int", 42), + attribute.Int64("val_int64", 42000000000), + attribute.Float64("val_float32", 3.14), + attribute.Float64("val_float64", 99.718281828), + attribute.Bool("val_bool", true), + attribute.StringSlice("val_str_slice", []string{"a_string_1", "a_string_2", "a_string_3"}), + attribute.IntSlice("val_int_slice", []int{1, 2, 3}), + attribute.Int64Slice("val_int64_slice", []int64{100000, 200000, 300000}), + attribute.Float64Slice("val_float32_slice", []float64{1.1, 2.2, 3.3}), + attribute.Float64Slice("val_float64_slice", []float64{4.4, 5.5, 6.6}), + attribute.BoolSlice("val_bool_slice", []bool{true, false, true, false}), + ), + ) + defer span.End() + } + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..944894b --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module github.com/remychantenay/otel-tag + +go 1.23 + +require ( + go.opentelemetry.io/otel v1.31.0 + go.opentelemetry.io/otel/sdk v1.31.0 + go.opentelemetry.io/otel/trace v1.31.0 +) + +require ( + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + go.opentelemetry.io/otel/metric v1.31.0 // indirect + golang.org/x/sys v0.26.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..60fcc68 --- /dev/null +++ b/go.sum @@ -0,0 +1,27 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= +go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= +go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= +go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= +go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= +go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= +go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= +go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/baggage.go b/internal/baggage.go new file mode 100644 index 0000000..616cf9a --- /dev/null +++ b/internal/baggage.go @@ -0,0 +1,92 @@ +package internal + +import ( + "fmt" + "reflect" + "strconv" + "strings" + + "go.opentelemetry.io/otel/baggage" +) + +// BaggageMember creates and returns an OpenTelemetry baggageMember for the provided field. +// Also returns a boolean that indicates whether or not the field's value is a zero-value. +func BaggageMember(field reflect.StructField, value reflect.Value, memberKey string) (baggage.Member, bool) { + + switch field.Type.Kind() { + case reflect.String: + v := value.String() + m, _ := baggage.NewMemberRaw(memberKey, v) + return m, v == "" + case reflect.Int: + v := value.Int() + m, _ := baggage.NewMemberRaw(memberKey, strconv.Itoa(int(v))) + return m, v == 0 + case reflect.Int64: + v := value.Int() + m, _ := baggage.NewMemberRaw(memberKey, strconv.FormatInt(v, 10)) + return m, v == 0 + case reflect.Float32, reflect.Float64: + v := value.Float() + m, _ := baggage.NewMemberRaw(memberKey, strconv.FormatFloat(v, 'f', -1, 64)) + return m, v == 0.0 + case reflect.Bool: + m, _ := baggage.NewMemberRaw(memberKey, strconv.FormatBool(value.Bool())) + return m, false + case reflect.Slice: + switch field.Type.Elem().Kind() { + case reflect.String: + if s, ok := value.Interface().([]string); ok { + m, _ := baggage.NewMemberRaw(memberKey, strings.Join(s, ",")) + return m, len(s) == 0 + } + case reflect.Int: + if s, ok := value.Interface().([]int); ok { + sStr := make([]string, len(s)) + for i, v := range s { + sStr[i] = strconv.Itoa(int(v)) + } + m, _ := baggage.NewMemberRaw(memberKey, strings.Join(sStr, ",")) + return m, len(s) == 0 + } + case reflect.Int64: + if s, ok := value.Interface().([]int64); ok { + sStr := make([]string, len(s)) + for i, v := range s { + sStr[i] = strconv.FormatInt(v, 10) + } + m, _ := baggage.NewMemberRaw(memberKey, strings.Join(sStr, ",")) + return m, len(s) == 0 + } + case reflect.Float32: + if s, ok := value.Interface().([]float32); ok { + sStr := make([]string, len(s)) + for i, v := range s { + sStr[i] = fmt.Sprintf("%f", v) + } + m, _ := baggage.NewMemberRaw(memberKey, strings.Join(sStr, ",")) + return m, len(s) == 0 + } + case reflect.Float64: + if s, ok := value.Interface().([]float64); ok { + sStr := make([]string, len(s)) + for i, v := range s { + sStr[i] = strconv.FormatFloat(v, 'f', -1, 64) + } + m, _ := baggage.NewMemberRaw(memberKey, strings.Join(sStr, ",")) + return m, len(s) == 0 + } + case reflect.Bool: + if s, ok := value.Interface().([]bool); ok { + sStr := make([]string, len(s)) + for i, v := range s { + sStr[i] = strconv.FormatBool(v) + } + m, _ := baggage.NewMemberRaw(memberKey, strings.Join(sStr, ",")) + return m, len(s) == 0 + } + } + } + + return baggage.Member{}, true +} diff --git a/internal/internal.go b/internal/internal.go new file mode 100644 index 0000000..f545f5c --- /dev/null +++ b/internal/internal.go @@ -0,0 +1,13 @@ +package internal + +import ( + "reflect" +) + +// tagName used by this library. +const tagName = "otel" + +// ExtractTag extract the tag's value of a given Struct field index. +func ExtractTag(value reflect.Value, index int) string { + return value.Type().Field(index).Tag.Get(tagName) +} diff --git a/internal/span.go b/internal/span.go new file mode 100644 index 0000000..b1103b7 --- /dev/null +++ b/internal/span.go @@ -0,0 +1,63 @@ +package internal + +import ( + "reflect" + + "go.opentelemetry.io/otel/attribute" +) + +// OTelAttribute creates and returns an OpenTelemetry span attribute for the provided field. +// Also returns a boolean that indicates whether or not the field's value is a zero-value. +func OTelAttribute(field reflect.StructField, value reflect.Value, attrKey string) (attribute.KeyValue, bool) { + + switch field.Type.Kind() { + case reflect.String: + v := value.String() + return attribute.String(attrKey, v), v == "" + case reflect.Int: + v := value.Int() + return attribute.Int(attrKey, int(v)), v == 0 + case reflect.Int64: + v := value.Int() + return attribute.Int64(attrKey, v), v == 0 + case reflect.Float32, reflect.Float64: + v := value.Float() + return attribute.Float64(attrKey, v), v == 0.0 + case reflect.Bool: + return attribute.Bool(attrKey, value.Bool()), false + case reflect.Slice: + switch field.Type.Elem().Kind() { + case reflect.String: + if s, ok := value.Interface().([]string); ok { + return attribute.StringSlice(attrKey, s), len(s) == 0 + } + case reflect.Int: + if s, ok := value.Interface().([]int); ok { + return attribute.IntSlice(attrKey, s), len(s) == 0 + } + case reflect.Int64: + if s, ok := value.Interface().([]int64); ok { + return attribute.Int64Slice(attrKey, s), len(s) == 0 + } + case reflect.Float32: + if s, ok := value.Interface().([]float32); ok { + s64 := make([]float64, len(s)) + for i, v := range s { + s64[i] = float64(v) + } + + return attribute.Float64Slice(attrKey, s64), len(s64) == 0 + } + case reflect.Float64: + if s, ok := value.Interface().([]float64); ok { + return attribute.Float64Slice(attrKey, s), len(s) == 0 + } + case reflect.Bool: + if s, ok := value.Interface().([]bool); ok { + return attribute.BoolSlice(attrKey, s), len(s) == 0 + } + } + } + + return attribute.KeyValue{}, true +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..489e5e8 --- /dev/null +++ b/main.go @@ -0,0 +1,6 @@ +package oteltag + +const ( + valIgnore = "-" + valOmitEmpty = "omitempty" +) diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..fb731e7 --- /dev/null +++ b/main_test.go @@ -0,0 +1,16 @@ +package oteltag_test + +type model struct { + ValStr string `otel:"val_str,omitempty"` + ValInt int `otel:"val_int,omitempty"` + ValInt64 int64 `otel:"val_int64,omitempty"` + ValFloat32 float32 `otel:"val_float32,omitempty"` + ValFloat64 float64 `otel:"val_float64,omitempty"` + ValBool bool `otel:"val_bool,omitempty"` + ValStrSlice []string `otel:"val_str_slice,omitempty"` + ValIntSlice []int `otel:"val_int_slice,omitempty"` + ValInt64Slice []int64 `otel:"val_int64_slice,omitempty"` + ValFloat32Slice []float32 `otel:"val_float32_slice,omitempty"` + ValFloat64Slice []float64 `otel:"val_float64_slice,omitempty"` + ValBoolSlice []bool `otel:"val_bool_slice,omitempty"` +} diff --git a/span.go b/span.go new file mode 100644 index 0000000..3bbc9e9 --- /dev/null +++ b/span.go @@ -0,0 +1,49 @@ +package oteltag + +import ( + "reflect" + "strings" + + "go.opentelemetry.io/otel/attribute" + + "github.com/remychantenay/otel-tag/internal" +) + +// SpanAttributes takes in a struct and spits out OpenTelemetry span attributes +// based on the struct tags. +func SpanAttributes(res any) []attribute.KeyValue { + structValue := reflect.ValueOf(res) + structType := structValue.Type() + fieldCount := structValue.NumField() + + if fieldCount == 0 { + return nil + } + + attrs := make([]attribute.KeyValue, 0, fieldCount) + for i := 0; i < fieldCount; i++ { + tag := internal.ExtractTag(structValue, i) + + if len(tag) == 0 || tag == valIgnore { + continue + } + + var omitEmpty bool + before, after, found := strings.Cut(tag, ",") + if found { + tag = before + if after == valOmitEmpty { + omitEmpty = true + } + } + + field, value := structType.Field(i), structValue.Field(i) + + attr, zeroValue := internal.OTelAttribute(field, value, tag) + if !zeroValue || !omitEmpty { + attrs = append(attrs, attr) + } + } + + return attrs +} diff --git a/span_test.go b/span_test.go new file mode 100644 index 0000000..f346345 --- /dev/null +++ b/span_test.go @@ -0,0 +1,139 @@ +package oteltag_test + +import ( + "context" + "testing" + + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "go.opentelemetry.io/otel/trace" + + oteltag "github.com/remychantenay/otel-tag" +) + +func TestSpanAttributes(t *testing.T) { + const testOperationName = "span" + + setupTracer := func() (*tracetest.SpanRecorder, trace.Tracer) { + spanRecorder := tracetest.NewSpanRecorder() + traceProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) + tracer := traceProvider.Tracer("test-tracer") + + return spanRecorder, tracer + } + + t.Run("when non-zero values - should add all attributes to span", func(t *testing.T) { + const ( + wantSpanCount = 1 + expectedAttributeCount = 12 + ) + + spanRecorder, tracer := setupTracer() + + m := model{ + ValStr: "a_string", + ValInt: 42, + ValInt64: 42000000000, + ValFloat32: 3.14, + ValFloat64: 99.718281828, + ValBool: true, + ValStrSlice: []string{"a_string_1", "a_string_2", "a_string_3"}, + ValIntSlice: []int{1, 2, 3}, + ValInt64Slice: []int64{100000, 200000, 300000}, + ValFloat32Slice: []float32{1.1, 2.2, 3.3}, + ValFloat64Slice: []float64{4.4, 5.5, 6.6}, + ValBoolSlice: []bool{true, false, true, false}, + } + + func() { + _, span := tracer.Start( + context.Background(), + testOperationName, + trace.WithAttributes(oteltag.SpanAttributes(m)...), + ) + defer span.End() + }() + + spans := spanRecorder.Ended() + if len(spans) != wantSpanCount { + t.Errorf("\ngot %d spans\nwant %d", len(spans), wantSpanCount) + } + + attrCount := len(spans[0].Attributes()) + if attrCount != expectedAttributeCount { + t.Errorf("\ngot %d attributes\nwant %d", attrCount, expectedAttributeCount) + } + + // for i := range spans[0].Attributes() { + // t.Log(spans[0].Attributes()[i].Value) + // } + }) + + t.Run("when zero values and omitted - should not add attributes to span", func(t *testing.T) { + const ( + wantSpanCount = 1 + expectedAttributeCount = 1 + ) + + spanRecorder, tracer := setupTracer() + + m := model{ValBool: true} + + func() { + _, span := tracer.Start( + context.Background(), + testOperationName, + trace.WithAttributes(oteltag.SpanAttributes(m)...), + ) + defer span.End() + }() + + spans := spanRecorder.Ended() + if len(spans) != wantSpanCount { + t.Errorf("\ngot %d spans\nwant %d", len(spans), wantSpanCount) + } + + attrCount := len(spans[0].Attributes()) + if attrCount != expectedAttributeCount { + t.Errorf("\ngot %d attributes\nwant %d", attrCount, expectedAttributeCount) + } + }) + + t.Run("when zero values and not omitted - should add attributes to span", func(t *testing.T) { + const ( + wantSpanCount = 1 + expectedAttributeCount = 1 + ) + + spanRecorder, tracer := setupTracer() + + m := struct { + ValStrNotOmitted string `otel:"val_str_not_omitted"` + }{ + ValStrNotOmitted: "", + } + + func() { + _, span := tracer.Start( + context.Background(), + testOperationName, + trace.WithAttributes(oteltag.SpanAttributes(m)...), + ) + defer span.End() + }() + + spans := spanRecorder.Ended() + if len(spans) != wantSpanCount { + t.Errorf("\ngot %d spans\nwant %d", len(spans), wantSpanCount) + } + + attrCount := len(spans[0].Attributes()) + if attrCount != expectedAttributeCount { + t.Errorf("\ngot %d attributes\nwant %d", attrCount, expectedAttributeCount) + } + + for i := range spans[0].Attributes() { + t.Log(spans[0].Attributes()[i].Key) + } + }) +}