├── .editorconfig ├── .github ├── CODEOWNERS └── workflows │ └── main.yaml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── Makefile ├── README.md ├── benchmark_test.go ├── doc.go ├── go.mod ├── go.sum ├── slog_otel.go └── slog_otel_test.go /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = space 7 | indent_size = 4 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [Makefile] 12 | indent_style = tab 13 | tab_width = 4 14 | 15 | [*.go] 16 | indent_style = tab 17 | tab_width = 4 18 | 19 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | @remychantenay 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Main CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v4 18 | with: 19 | go-version: '1.23' 20 | 21 | - name: Install golangci-lint 22 | run: go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.60.2 23 | 24 | - name: Install govulncheck 25 | run: go install golang.org/x/vuln/cmd/govulncheck@latest 26 | 27 | - name: Linting 28 | run: make lint 29 | 30 | - name: govulncheck 31 | run: govulncheck ./... 32 | 33 | - name: Testing 34 | run: make test 35 | 36 | - name: Upload coverage reports to Codecov 37 | uses: codecov/codecov-action@v3 38 | env: 39 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | *.exe~ 3 | *.dll 4 | *.so 5 | *.dylib 6 | *.test 7 | *.out 8 | vendor/ 9 | .DS_Store 10 | .idea 11 | .vscode 12 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | disable-all: false 3 | 4 | linters-settings: 5 | goimports: 6 | local-prefixes: github.com/remychantenay/slog-otel 7 | 8 | run: 9 | timeout: 10m 10 | modules-download-mode: readonly 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: test 2 | 3 | .PHONY: lint 4 | lint: 5 | golangci-lint run ./... 6 | 7 | .PHONY: test 8 | test: 9 | go test -race -count=1 ./... 10 | 11 | .PHONY: bench 12 | bench: 13 | go test -bench=. -benchtime=10x 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # slog-otel 2 | [![Go Report Card](https://goreportcard.com/badge/github.com/remychantenay/slog-otel)](https://goreportcard.com/report/github.com/remychantenay/slog-otel) 3 | [![codebeat badge](https://codebeat.co/badges/33ebce8f-9681-4c9c-8c43-f9ab4f197d9e)](https://codebeat.co/projects/github-com-remychantenay-slog-otel-main) 4 | [![GoDoc](https://godoc.org/github.com/remychantenay/slog-otel?status.svg)](https://godoc.org/github.com/remychantenay/slog-otel) 5 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 6 | 7 | Go package that provides an implementation of `log/slog`'s [Handler interface](https://pkg.go.dev/log/slog#Handler) that ensures a strong correlation between log records and [Open-Telemetry spans](https://opentelemetry.io/docs/concepts/signals/traces/#spans) by... 8 | 9 | 1. Adding [span and trace IDs](https://opentelemetry.io/docs/concepts/signals/traces/#span-context) to the log record. 10 | 2. Adding context [baggage](https://opentelemetry.io/docs/concepts/signals/baggage/) members to the log record (can be disabled). 11 | 3. Adding log record as [span event](https://opentelemetry.io/docs/concepts/signals/traces/#span-events) (can be disabled). 12 | 4. Adding log record attributes to the span event (can be disabled). 13 | 5. Setting [span status](https://opentelemetry.io/docs/concepts/signals/traces/#span-status) based on slog record level (only if >= slog.LevelError). 14 | 15 | ## Usage 16 | ```go 17 | import ( 18 | "context" 19 | "log/slog" 20 | 21 | "go.opentelemetry.io/otel/baggage" 22 | "go.opentelemetry.io/otel/trace" 23 | 24 | sdktrace "go.opentelemetry.io/otel/sdk/trace" 25 | slogotel "github.com/remychantenay/slog-otel" 26 | ) 27 | 28 | // 1. Configure slog. 29 | slog.SetDefault(slog.New(slogotel.OtelHandler{ 30 | Next: slog.NewJSONHandler(os.Stdout, nil), 31 | })) 32 | 33 | // 2. Set up your logger. 34 | logger := slog.Default() 35 | logger = logger.With("component", "server") 36 | 37 | // 3. (Optional) Add baggage to your context. 38 | m1, _ := baggage.NewMember("key_1", "value_1") 39 | m2, _ := baggage.NewMember("key_2", "value_2") 40 | bag, _ := baggage.New(m1, m2) 41 | ctx := baggage.ContextWithBaggage(context.Background(), bag) 42 | 43 | // 4. Start your span. 44 | ctx, span := tracer.Start(ctx, "operation-name") 45 | defer span.End() 46 | 47 | // 5. Log. 48 | logger.InfoContext(ctx, "Hello world!", "locale", "en_US") 49 | ``` 50 | 51 | #### Example 52 | The following initial log: 53 | ```json 54 | { 55 | "time": "2023-09-11T08:28:02.77215605Z", 56 | "level": "INFO", 57 | "component": "server", 58 | "msg": "Hello world!", 59 | "locale": "en_US" 60 | } 61 | ``` 62 | ... will be written as: 63 | ```json 64 | { 65 | "time": "2023-09-11T08:28:02.77215605Z", 66 | "level": "INFO", 67 | "component": "server", 68 | "msg": "Hello world!", 69 | "locale": "en_US", 70 | "trace_id": "a9938fd7a6313e0f27f3fc87f574bff6", 71 | "span_id": "ed58f84d8971bf60", 72 | "key_1": "value_1", 73 | "key_2": "value_2" 74 | } 75 | ``` 76 | 77 | and the related span will look like: 78 | ```json 79 | { 80 | "Name": "GET /resources", 81 | "SpanContext": { 82 | "TraceID": "a9938fd7a6313e0f27f3fc87f574bff6", 83 | "SpanID": "ed58f84d8971bf60", 84 | ... 85 | }, 86 | "Parent": { 87 | ... 88 | }, 89 | "SpanKind": 2, 90 | "StartTime": "2023-09-11T08:28:02.761992425Z", 91 | "EndTime": "2023-09-11T08:28:02.773230425Z", 92 | "Attributes": [{ 93 | "Key": "http.method", 94 | "Value": { 95 | "Type": "STRING", 96 | "Value": "GET" 97 | } 98 | }, 99 | ... 100 | ], 101 | "Events": [{ 102 | "Name": "log_record", 103 | "Attributes": [{ 104 | "Key": "msg", 105 | "Value": { 106 | "Type": "STRING", 107 | "Value": "Hello world!" 108 | } 109 | }, 110 | { 111 | "Key": "level", 112 | "Value": { 113 | "Type": "STRING", 114 | "Value": "INFO" 115 | } 116 | }, 117 | { 118 | "Key": "time", 119 | "Value": { 120 | "Type": "STRING", 121 | "Value": "2023-09-11T08:28:02.77215605Z" 122 | } 123 | }, 124 | { 125 | "Key": "locale", 126 | "Value": { 127 | "Type": "STRING", 128 | "Value": "en_US" 129 | } 130 | }, 131 | { 132 | "Key": "component", 133 | "Value": { 134 | "Type": "STRING", 135 | "Value": "server" 136 | } 137 | } 138 | ], 139 | }], 140 | ... 141 | } 142 | ``` 143 | 144 | ## License 145 | Apache License Version 2.0 146 | -------------------------------------------------------------------------------- /benchmark_test.go: -------------------------------------------------------------------------------- 1 | package slogotel_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "log/slog" 7 | "testing" 8 | "time" 9 | 10 | "go.opentelemetry.io/otel/baggage" 11 | sdktrace "go.opentelemetry.io/otel/sdk/trace" 12 | "go.opentelemetry.io/otel/sdk/trace/tracetest" 13 | "go.opentelemetry.io/otel/trace" 14 | 15 | slogotel "github.com/remychantenay/slog-otel" 16 | ) 17 | 18 | var ( 19 | loggerWithOtel *slog.Logger 20 | logger *slog.Logger 21 | traceExporter *tracetest.InMemoryExporter 22 | tracer trace.Tracer 23 | ) 24 | 25 | func init() { 26 | logger = setUploggerWithoutOtelHandler() 27 | loggerWithOtel = setUploggerWithOtelHandler() 28 | traceExporter, tracer = setUpBenchmarkTracer() 29 | } 30 | 31 | func setUploggerWithOtelHandler() *slog.Logger { 32 | var buffer bytes.Buffer 33 | return slog.New(slogotel.OtelHandler{ 34 | Next: slog.NewJSONHandler(&buffer, nil), 35 | }) 36 | } 37 | 38 | func setUploggerWithoutOtelHandler() *slog.Logger { 39 | var buffer bytes.Buffer 40 | return slog.New(slog.NewJSONHandler(&buffer, nil)) 41 | } 42 | 43 | func setUpBenchmarkTracer() (*tracetest.InMemoryExporter, trace.Tracer) { 44 | exporter := tracetest.NewInMemoryExporter() 45 | traceProvider := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exporter)) 46 | tracer := traceProvider.Tracer("benchmark-tracer") 47 | 48 | return exporter, tracer 49 | } 50 | 51 | func BenchmarkJSONHandler_SimpleLog(b *testing.B) { 52 | ctx := context.Background() 53 | 54 | b.ResetTimer() 55 | logger.InfoContext(ctx, "Hello world!") 56 | } 57 | 58 | func BenchmarkOtelHandler_SimpleLog(b *testing.B) { 59 | ctx := context.Background() 60 | 61 | b.ResetTimer() 62 | b.RunParallel(func(pb *testing.PB) { 63 | for pb.Next() { 64 | loggerWithOtel.InfoContext(ctx, "Hello world!") 65 | } 66 | }) 67 | } 68 | 69 | func BenchmarkJSONHandler_WithSpan(b *testing.B) { 70 | traceExporter.Reset() 71 | 72 | m1, _ := baggage.NewMember("key1b", "value1b") 73 | m2, _ := baggage.NewMember("key2b", "value2b") 74 | bag, _ := baggage.New(m1, m2) 75 | ctx := baggage.ContextWithBaggage(context.Background(), bag) 76 | 77 | b.ResetTimer() 78 | b.RunParallel(func(pb *testing.PB) { 79 | for pb.Next() { 80 | ctx, span := tracer.Start(ctx, "operation-name") 81 | defer span.End() 82 | 83 | group1 := slog.Group("group_1", "key_1", "value_1") 84 | group2 := slog.Group("group_2", "key_2", "value_2") 85 | logger.InfoContext(ctx, "Hello world!", 86 | "key1", "value1", 87 | "key2", 42.0, 88 | "key3", 42, 89 | "key4", true, 90 | "key5", time.Now(), 91 | group1, 92 | group2, 93 | ) 94 | } 95 | }) 96 | } 97 | 98 | func BenchmarkOtelHandler_WithSpan(b *testing.B) { 99 | traceExporter.Reset() 100 | 101 | m1, _ := baggage.NewMember("key1b", "value1b") 102 | m2, _ := baggage.NewMember("key2b", "value2b") 103 | bag, _ := baggage.New(m1, m2) 104 | ctx := baggage.ContextWithBaggage(context.Background(), bag) 105 | 106 | b.ResetTimer() 107 | b.RunParallel(func(pb *testing.PB) { 108 | for pb.Next() { 109 | ctx, span := tracer.Start(ctx, "operation-name") 110 | defer span.End() 111 | 112 | group1 := slog.Group("group_1", "key_1", "value_1") 113 | group2 := slog.Group("group_2", "key_2", "value_2") 114 | loggerWithOtel.InfoContext(ctx, "Hello world!", 115 | "key1", "value1", 116 | "key2", 42.0, 117 | "key3", 42, 118 | "key4", true, 119 | "key5", time.Now(), 120 | group1, 121 | group2, 122 | ) 123 | } 124 | }) 125 | } 126 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package slogotel provides a custom handler for `log/slog` to ensures strong correlation between log records and Open-Telemetry spans. 3 | 4 | # Usage 5 | 6 | import ( 7 | "context" 8 | "log/slog" 9 | 10 | "go.opentelemetry.io/otel/baggage" 11 | "go.opentelemetry.io/otel/trace" 12 | sdktrace "go.opentelemetry.io/otel/sdk/trace" 13 | slogotel "github.com/remychantenay/slog-otel" 14 | ) 15 | 16 | // 1. Configure slog. 17 | slog.SetDefault(slog.New(slogotel.OtelHandler{ 18 | Next: slog.NewJSONHandler(os.Stdout, nil), 19 | })) 20 | 21 | // 2. Set up your logger. 22 | logger := slog.Default() 23 | logger = logger.With("component", "server") 24 | 25 | // 3. (Optional) Add baggage to your context. 26 | m1, _ := baggage.NewMember("key_1", "value_1") 27 | m2, _ := baggage.NewMember("key_2", "value_2") 28 | bag, _ := baggage.New(m1, m2) 29 | ctx := baggage.ContextWithBaggage(context.Background(), bag) 30 | 31 | // 4. Start your span. 32 | tracer := sdktrace.NewTracerProvider().Tracer("server") 33 | ctx, span := tracer.Start(ctx, "operation-name") 34 | defer span.End() 35 | 36 | // 5. Log. 37 | logger.InfoContext(ctx, "Hello world!", "locale", "en_US") 38 | */ 39 | package slogotel 40 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/remychantenay/slog-otel 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | go.opentelemetry.io/otel v1.36.0 7 | go.opentelemetry.io/otel/sdk v1.36.0 8 | go.opentelemetry.io/otel/trace v1.36.0 9 | ) 10 | 11 | require ( 12 | github.com/go-logr/logr v1.4.2 // indirect 13 | github.com/go-logr/stdr v1.2.2 // indirect 14 | github.com/google/uuid v1.6.0 // indirect 15 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 16 | go.opentelemetry.io/otel/metric v1.36.0 // indirect 17 | golang.org/x/sys v0.33.0 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 4 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 5 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 6 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 7 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 8 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 9 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 10 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 11 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 12 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 13 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 15 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 16 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 17 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 18 | go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= 19 | go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= 20 | go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= 21 | go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= 22 | go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= 23 | go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= 24 | go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= 25 | go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= 26 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 27 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 28 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 29 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 30 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 31 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 32 | -------------------------------------------------------------------------------- /slog_otel.go: -------------------------------------------------------------------------------- 1 | package slogotel 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "time" 8 | 9 | "go.opentelemetry.io/otel/attribute" 10 | "go.opentelemetry.io/otel/baggage" 11 | "go.opentelemetry.io/otel/codes" 12 | "go.opentelemetry.io/otel/trace" 13 | ) 14 | 15 | const ( 16 | // TraceIDKey is the key used by the Otel handler 17 | // to inject the trace ID in the log record. 18 | TraceIDKey = "trace_id" 19 | // SpanIDKey is the key used by the Otel handler 20 | // to inject the span ID in the log record. 21 | SpanIDKey = "span_id" 22 | // SpanEventKey is the key used by the Otel handler 23 | // to inject the log record in the recording span, as a span event. 24 | SpanEventKey = "log_record" 25 | ) 26 | 27 | // OtelHandler is an implementation of slog's Handler interface. 28 | // Its role is to ensure correlation between logs and OTel spans 29 | // by: 30 | // 31 | // 1. Adding otel span and trace IDs to the log record. 32 | // 2. Adding otel context baggage members to the log record. 33 | // 3. Setting slog record as otel span event. 34 | // 4. Adding slog record attributes to the otel span event. 35 | // 5. Setting span status based on slog record level (only if >= slog.LevelError). 36 | type OtelHandler struct { 37 | // Next represents the next handler in the chain. 38 | Next slog.Handler 39 | // NoBaggage determines whether to add context baggage members to the log record. 40 | NoBaggage bool 41 | // NoTraceEvents determines whether to record an event for every log on the active trace. 42 | NoTraceEvents bool 43 | } 44 | 45 | type OtelHandlerOpt func(handler *OtelHandler) 46 | 47 | // HandlerFn defines the handler used by slog.Handler as return value. 48 | type HandlerFn func(slog.Handler) slog.Handler 49 | 50 | // WithNoBaggage returns an OtelHandlerOpt, which sets the NoBaggage flag 51 | func WithNoBaggage(noBaggage bool) OtelHandlerOpt { 52 | return func(handler *OtelHandler) { 53 | handler.NoBaggage = noBaggage 54 | } 55 | } 56 | 57 | // WithNoTraceEvents returns an OtelHandlerOpt, which sets the NoTraceEvents flag 58 | func WithNoTraceEvents(noTraceEvents bool) OtelHandlerOpt { 59 | return func(handler *OtelHandler) { 60 | handler.NoTraceEvents = noTraceEvents 61 | } 62 | } 63 | 64 | // New creates a new OtelHandler to use with log/slog 65 | func New(next slog.Handler, opts ...OtelHandlerOpt) *OtelHandler { 66 | ret := &OtelHandler{ 67 | Next: next, 68 | } 69 | for _, opt := range opts { 70 | opt(ret) 71 | } 72 | return ret 73 | } 74 | 75 | // NewOtelHandler creates and returns a new HandlerFn, which wraps a handler with OtelHandler to use with log/slog. 76 | func NewOtelHandler(opts ...OtelHandlerOpt) HandlerFn { 77 | return func(next slog.Handler) slog.Handler { 78 | return New(next, opts...) 79 | } 80 | } 81 | 82 | // Handle handles the provided log record and adds correlation between a slog record and an Open-Telemetry span. 83 | func (h OtelHandler) Handle(ctx context.Context, record slog.Record) error { 84 | if ctx == nil { 85 | return h.Next.Handle(ctx, record) 86 | } 87 | 88 | if !h.NoBaggage { 89 | // Adding context baggage members to log record. 90 | b := baggage.FromContext(ctx) 91 | for _, m := range b.Members() { 92 | record.AddAttrs(slog.String(m.Key(), m.Value())) 93 | } 94 | } 95 | 96 | span := trace.SpanFromContext(ctx) 97 | if span == nil || !span.IsRecording() { 98 | return h.Next.Handle(ctx, record) 99 | } 100 | 101 | if !h.NoTraceEvents { 102 | // Adding log info to span event. 103 | eventAttrs := make([]attribute.KeyValue, 0, record.NumAttrs()) 104 | eventAttrs = append(eventAttrs, attribute.String(slog.MessageKey, record.Message)) 105 | eventAttrs = append(eventAttrs, attribute.String(slog.LevelKey, record.Level.String())) 106 | eventAttrs = append(eventAttrs, attribute.String(slog.TimeKey, record.Time.Format(time.RFC3339Nano))) 107 | record.Attrs(func(attr slog.Attr) bool { 108 | otelAttr := h.slogAttrToOtelAttr(attr) 109 | if otelAttr.Valid() { 110 | eventAttrs = append(eventAttrs, otelAttr) 111 | } 112 | 113 | return true 114 | }) 115 | 116 | span.AddEvent(SpanEventKey, trace.WithAttributes(eventAttrs...)) 117 | } 118 | 119 | // Adding span info to log record. 120 | spanContext := span.SpanContext() 121 | if spanContext.HasTraceID() { 122 | traceID := spanContext.TraceID().String() 123 | record.AddAttrs(slog.String(TraceIDKey, traceID)) 124 | } 125 | 126 | if spanContext.HasSpanID() { 127 | spanID := spanContext.SpanID().String() 128 | record.AddAttrs(slog.String(SpanIDKey, spanID)) 129 | } 130 | 131 | // Setting span status if the log is an error. 132 | // Purposely leaving as codes.Unset (default) otherwise. 133 | if record.Level >= slog.LevelError { 134 | span.SetStatus(codes.Error, record.Message) 135 | } 136 | 137 | return h.Next.Handle(ctx, record) 138 | } 139 | 140 | // WithAttrs returns a new Otel whose attributes consists of handler's attributes followed by attrs. 141 | func (h OtelHandler) WithAttrs(attrs []slog.Attr) slog.Handler { 142 | return OtelHandler{ 143 | Next: h.Next.WithAttrs(attrs), 144 | NoBaggage: h.NoBaggage, 145 | NoTraceEvents: h.NoTraceEvents, 146 | } 147 | } 148 | 149 | // WithGroup returns a new Otel with a group, provided the group's name. 150 | func (h OtelHandler) WithGroup(name string) slog.Handler { 151 | return OtelHandler{ 152 | Next: h.Next.WithGroup(name), 153 | NoBaggage: h.NoBaggage, 154 | NoTraceEvents: h.NoTraceEvents, 155 | } 156 | } 157 | 158 | // Enabled reports whether the logger emits log records at the given context and level. 159 | // Note: We handover the decision down to the next handler. 160 | func (h OtelHandler) Enabled(ctx context.Context, level slog.Level) bool { 161 | return h.Next.Enabled(ctx, level) 162 | } 163 | 164 | // slogAttrToOtelAttr converts a slog attribute to an OTel one. 165 | // Note: returns an empty attribute if the provided slog attribute is empty. 166 | func (h OtelHandler) slogAttrToOtelAttr(attr slog.Attr, groupKeys ...string) attribute.KeyValue { 167 | attr.Value = attr.Value.Resolve() 168 | if attr.Equal(slog.Attr{}) { 169 | return attribute.KeyValue{} 170 | } 171 | 172 | key := func(k string, prefixes ...string) string { 173 | for _, prefix := range prefixes { 174 | k = fmt.Sprintf("%s.%s", prefix, k) 175 | } 176 | 177 | return k 178 | }(attr.Key, groupKeys...) 179 | 180 | value := attr.Value.Resolve() 181 | 182 | switch attr.Value.Kind() { 183 | case slog.KindBool: 184 | return attribute.Bool(key, value.Bool()) 185 | case slog.KindFloat64: 186 | return attribute.Float64(key, value.Float64()) 187 | case slog.KindInt64: 188 | return attribute.Int64(key, value.Int64()) 189 | case slog.KindString: 190 | return attribute.String(key, value.String()) 191 | case slog.KindTime: 192 | return attribute.String(key, value.Time().Format(time.RFC3339Nano)) 193 | case slog.KindGroup: 194 | groupAttrs := value.Group() 195 | if len(groupAttrs) == 0 { 196 | return attribute.KeyValue{} 197 | } 198 | 199 | for _, groupAttr := range groupAttrs { 200 | return h.slogAttrToOtelAttr(groupAttr, append(groupKeys, key)...) 201 | } 202 | case slog.KindAny: 203 | switch v := attr.Value.Any().(type) { 204 | case []string: 205 | return attribute.StringSlice(key, v) 206 | case []int: 207 | return attribute.IntSlice(key, v) 208 | case []int64: 209 | return attribute.Int64Slice(key, v) 210 | case []float64: 211 | return attribute.Float64Slice(key, v) 212 | case []bool: 213 | return attribute.BoolSlice(key, v) 214 | default: 215 | return attribute.String(key, fmt.Sprintf("%+v", v)) 216 | } 217 | default: 218 | return attribute.KeyValue{} 219 | } 220 | 221 | return attribute.KeyValue{} 222 | } 223 | -------------------------------------------------------------------------------- /slog_otel_test.go: -------------------------------------------------------------------------------- 1 | package slogotel_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "errors" 8 | "log/slog" 9 | "strings" 10 | "testing" 11 | 12 | "go.opentelemetry.io/otel/attribute" 13 | "go.opentelemetry.io/otel/baggage" 14 | "go.opentelemetry.io/otel/codes" 15 | sdktrace "go.opentelemetry.io/otel/sdk/trace" 16 | "go.opentelemetry.io/otel/sdk/trace/tracetest" 17 | "go.opentelemetry.io/otel/trace" 18 | 19 | slogotel "github.com/remychantenay/slog-otel" 20 | ) 21 | 22 | func TestOtelHandler(t *testing.T) { 23 | const testOperationName = "operation-name" 24 | 25 | setupTracer := func() (*tracetest.SpanRecorder, trace.Tracer) { 26 | spanRecorder := tracetest.NewSpanRecorder() 27 | traceProvider := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(spanRecorder)) 28 | tracer := traceProvider.Tracer("test-tracer") 29 | 30 | return spanRecorder, tracer 31 | } 32 | 33 | setupLogger := func(opts ...slogotel.OtelHandlerOpt) *bytes.Buffer { 34 | var buffer bytes.Buffer 35 | slog.SetDefault(slog.New(slogotel.New(slog.NewJSONHandler(&buffer, nil), opts...))) 36 | 37 | return &buffer 38 | } 39 | 40 | t.Run("without span", func(t *testing.T) { 41 | buffer := setupLogger() 42 | 43 | want := map[string]string{ 44 | "level": "INFO", 45 | "msg": "without span", 46 | "a_key": "a_value", 47 | } 48 | 49 | func() { 50 | slog.Info("without span", "a_key", "a_value") 51 | }() 52 | 53 | got := map[string]string{} 54 | if err := json.Unmarshal([]byte(strings.TrimSuffix(buffer.String(), "\n")), &got); err != nil { 55 | panic(err) 56 | } 57 | 58 | for key := range want { 59 | if got[key] != want[key] { 60 | t.Errorf("\ngot %q for key %q\nwant %q", got[key], key, want[key]) 61 | } 62 | } 63 | }) 64 | 65 | t.Run("adds span and trace ids to log", func(t *testing.T) { 66 | spanRecorder, tracer := setupTracer() 67 | buffer := setupLogger() 68 | 69 | want := []string{"trace_id", "span_id"} 70 | 71 | func() { 72 | ctx := context.Background() 73 | ctx, span := tracer.Start(ctx, testOperationName) 74 | defer span.End() 75 | 76 | slog.InfoContext(ctx, "adds span and trace ids to log") 77 | }() 78 | 79 | got := map[string]string{} 80 | if err := json.Unmarshal([]byte(strings.TrimSuffix(buffer.String(), "\n")), &got); err != nil { 81 | t.Fatal(err) 82 | } 83 | 84 | spans := spanRecorder.Ended() 85 | if len(spans) != 1 { 86 | t.Errorf("\ngot %d spans\nwant %d", len(spans), 1) 87 | } 88 | 89 | for _, key := range want { 90 | if _, ok := got[key]; !ok { 91 | t.Errorf("\n%q attribute is missing", key) 92 | } 93 | } 94 | }) 95 | 96 | t.Run("adds event to span", func(t *testing.T) { 97 | spanRecorder, tracer := setupTracer() 98 | _ = setupLogger() 99 | 100 | want := []attribute.KeyValue{{ 101 | Key: "a_key", 102 | Value: attribute.StringValue("a_value"), 103 | }, { 104 | Key: "msg", 105 | Value: attribute.StringValue("adds event to span"), 106 | }, { 107 | Key: "level", 108 | Value: attribute.StringValue("INFO"), 109 | }, { 110 | Key: "string_slice", 111 | Value: attribute.StringSliceValue([]string{"value_1", "value_2"}), 112 | }, { 113 | Key: "int_slice", 114 | Value: attribute.IntSliceValue([]int{1, 2}), 115 | }, { 116 | Key: "int64_slice", 117 | Value: attribute.Int64SliceValue([]int64{1, 2}), 118 | }, { 119 | Key: "float64_slice", 120 | Value: attribute.Float64SliceValue([]float64{1.0, 2.0}), 121 | }, { 122 | Key: "bool_slice", 123 | Value: attribute.BoolSliceValue([]bool{true, false}), 124 | }, { 125 | 126 | Key: "group_1.key_1", 127 | Value: attribute.StringValue("value_1"), 128 | }, { 129 | Key: "group_2.key_2", 130 | Value: attribute.StringValue("value_2"), 131 | }, { 132 | Key: "err", 133 | Value: attribute.StringValue("boom"), 134 | }} 135 | 136 | func() { 137 | ctx := context.Background() 138 | ctx, span := tracer.Start(ctx, testOperationName) 139 | defer span.End() 140 | 141 | group1 := slog.Group("group_1", "key_1", "value_1") 142 | group2 := slog.Group("group_2", "key_2", "value_2") 143 | 144 | stringSlice := []string{"value_1", "value_2"} 145 | intSlice := []int{1, 2} 146 | int64Slice := []int64{1, 2} 147 | float64Slice := []float64{1.0, 2.0} 148 | boolSlice := []bool{true, false} 149 | 150 | slog.InfoContext(ctx, 151 | "adds event to span", 152 | "string_slice", stringSlice, 153 | "int_slice", intSlice, 154 | "int64_slice", int64Slice, 155 | "float64_slice", float64Slice, 156 | "bool_slice", boolSlice, 157 | "a_key", "a_value", 158 | "err", errors.New("boom"), 159 | group1, 160 | group2, 161 | ) 162 | }() 163 | 164 | spans := spanRecorder.Ended() 165 | 166 | if len(spans) != 1 { 167 | t.Errorf("\ngot %d spans\nwant %d", len(spans), 1) 168 | } 169 | 170 | expectedEventName := "log_record" 171 | if spans[0].Events()[0].Name != expectedEventName { 172 | t.Errorf("\ngot %q\nwant %q", spans[0].Events()[0].Name, expectedEventName) 173 | } 174 | 175 | for _, wantAttr := range want { 176 | found := false 177 | 178 | for _, gotAttr := range spans[0].Events()[0].Attributes { 179 | if wantAttr.Key == gotAttr.Key && 180 | wantAttr.Value == gotAttr.Value { 181 | found = true 182 | break 183 | } 184 | } 185 | 186 | if !found { 187 | t.Errorf("\nspan event attribute with key %v and value %v is missing", 188 | wantAttr.Key, wantAttr.Value) 189 | } 190 | } 191 | }) 192 | 193 | t.Run("adds context baggage attributes to log", func(t *testing.T) { 194 | spanRecorder, tracer := setupTracer() 195 | buffer := setupLogger() 196 | 197 | want := map[string]string{ 198 | "key1b": "value1b", 199 | "key2b": "value2b", 200 | } 201 | 202 | func() { 203 | m1, _ := baggage.NewMember("key1b", "value1b") 204 | m2, _ := baggage.NewMember("key2b", "value2b") 205 | bag, _ := baggage.New(m1, m2) 206 | ctx := baggage.ContextWithBaggage(context.Background(), bag) 207 | 208 | ctx, span := tracer.Start(ctx, testOperationName) 209 | defer span.End() 210 | 211 | slog.InfoContext(ctx, "adds context baggage attributes to log") 212 | }() 213 | 214 | spanRecorder.Ended() 215 | 216 | got := map[string]string{} 217 | if err := json.Unmarshal([]byte(strings.TrimSuffix(buffer.String(), "\n")), &got); err != nil { 218 | t.Fatal(err) 219 | } 220 | 221 | for key := range want { 222 | if got[key] != want[key] { 223 | t.Errorf("\ngot %q for key %q\nwant %q", got[key], key, want[key]) 224 | } 225 | } 226 | }) 227 | 228 | t.Run("does not set span status with non error logs", func(t *testing.T) { 229 | spanRecorder, tracer := setupTracer() 230 | _ = setupLogger() 231 | 232 | want := sdktrace.Status{ 233 | Code: codes.Unset, 234 | } 235 | 236 | func() { 237 | ctx := context.Background() 238 | 239 | ctx, span := tracer.Start(ctx, testOperationName) 240 | defer span.End() 241 | 242 | slog.InfoContext(ctx, "sets span status as error with error log") 243 | slog.DebugContext(ctx, "sets span status as error with error log") 244 | slog.WarnContext(ctx, "sets span status as error with error log") 245 | }() 246 | 247 | spans := spanRecorder.Ended() 248 | for _, span := range spans { 249 | if span.Status() != want { 250 | t.Errorf("\ngot %v\nwant %v", span.Status(), want) 251 | } 252 | } 253 | }) 254 | 255 | t.Run("sets span status as error with error log", func(t *testing.T) { 256 | spanRecorder, tracer := setupTracer() 257 | buffer := setupLogger() 258 | 259 | want := sdktrace.Status{ 260 | Code: codes.Error, 261 | Description: "an error", 262 | } 263 | 264 | func() { 265 | ctx := context.Background() 266 | 267 | ctx, span := tracer.Start(ctx, testOperationName) 268 | defer span.End() 269 | 270 | slog.ErrorContext(ctx, "an error") 271 | }() 272 | 273 | spans := spanRecorder.Ended() 274 | spans[0].Status() 275 | 276 | got := map[string]string{} 277 | if err := json.Unmarshal([]byte(strings.TrimSuffix(buffer.String(), "\n")), &got); err != nil { 278 | t.Fatal(err) 279 | } 280 | 281 | if spans[0].Status() != want { 282 | t.Errorf("\ngot %v\nwant %v", spans[0].Status(), want) 283 | } 284 | }) 285 | 286 | t.Run("when configured with NoTraceEvents, does not attach events to active trace", func(t *testing.T) { 287 | wantMsg := "a log without any trace events" 288 | spanRecorder, tracer := setupTracer() 289 | buffer := setupLogger(slogotel.WithNoTraceEvents(true)) 290 | 291 | func() { 292 | ctx, span := tracer.Start(context.Background(), testOperationName) 293 | defer span.End() 294 | slog.InfoContext(ctx, wantMsg) 295 | }() 296 | 297 | spans := spanRecorder.Ended() 298 | spans[0].Status() 299 | 300 | if eventsLen := len(spans[0].Events()); eventsLen > 0 { 301 | t.Errorf("Expected no events on the span, but there are %d: %v", eventsLen, spans[0].Events()) 302 | } 303 | got := map[string]string{} 304 | if err := json.Unmarshal([]byte(strings.TrimSuffix(buffer.String(), "\n")), &got); err != nil { 305 | t.Fatal(err) 306 | } 307 | if msg := got["msg"]; msg != wantMsg { 308 | t.Errorf("\ngot %v\nwant %v", msg, wantMsg) 309 | } 310 | }) 311 | } 312 | --------------------------------------------------------------------------------