diff --git a/data_for_test.go b/data_for_test.go index 11bf2bc..3f8824a 100644 --- a/data_for_test.go +++ b/data_for_test.go @@ -17,6 +17,7 @@ import ( "github.com/tobert/otel-cli/otelcli" "github.com/tobert/otel-cli/otlpclient" + logspb "go.opentelemetry.io/proto/otlp/logs/v1" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) @@ -72,15 +73,18 @@ type Results struct { ServerMeta map[string]string Headers map[string]string // headers sent by the client ResourceSpans *tracepb.ResourceSpans + ResourceLogs *logspb.ResourceLogs CliOutput string // merged stdout and stderr CliOutputRe *regexp.Regexp // regular expression to clean the output before comparison SpanCount int // number of spans received EventCount int // number of events received + LogCount int // number of log records received TimedOut bool // true when test timed out CommandFailed bool // otel-cli was killed, did not exit() on its own ExitCode int // the process exit code returned by otel-cli Span *tracepb.Span SpanEvents []*tracepb.Span_Event + LogRecord *logspb.LogRecord } // Fixture represents a test fixture for otel-cli. @@ -1383,7 +1387,7 @@ var suites = []FixtureSuite{ }, Expect: Results{ CliOutputRe: regexp.MustCompile(`.+`), // match and strip any content - CliOutput: "\n", // after strip, should be just newline + CliOutput: "\n", // after strip, should be just newline ExitCode: 0, }, CheckFuncs: []CheckFunc{ @@ -1409,7 +1413,7 @@ var suites = []FixtureSuite{ }, Expect: Results{ CliOutputRe: regexp.MustCompile(`.+`), // match and strip any content - CliOutput: "\n", // after strip, should be just newline + CliOutput: "\n", // after strip, should be just newline ExitCode: 0, }, CheckFuncs: []CheckFunc{ @@ -1468,4 +1472,44 @@ var suites = []FixtureSuite{ }, }, }, + // log command tests + { + { + Name: "basic log command (grpc)", + Config: FixtureConfig{ + ServerProtocol: grpcProtocol, + CliArgs: []string{ + "log", + "--endpoint", "{{endpoint}}", + "--service", "test-service", + "--body", "Test log message", + "--severity", "INFO", + "--attrs", "test.key=test.value", + "--fail", "--verbose", + }, + TestTimeoutMs: 1000, + }, + Expect: Results{ + LogCount: 1, + }, + }, + { + Name: "log with different severity levels", + Config: FixtureConfig{ + ServerProtocol: grpcProtocol, + CliArgs: []string{ + "log", + "--endpoint", "{{endpoint}}", + "--service", "test-service", + "--body", "Error message", + "--severity", "ERROR", + "--fail", "--verbose", + }, + TestTimeoutMs: 1000, + }, + Expect: Results{ + LogCount: 1, + }, + }, + }, } diff --git a/demos/02-simple-log.sh b/demos/02-simple-log.sh new file mode 100755 index 0000000..c862f58 --- /dev/null +++ b/demos/02-simple-log.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# an otel-cli log demo + +# Generate some sample data to include in the log +user_id=$(( RANDOM % 1000 )) +action="login" + +# Send a log message with attributes +../otel-cli log \ + --service "otel-cli-demo" \ + --body "User login successful" \ + --severity INFO \ + --attrs "user.id=$user_id,action=$action,ip=192.168.1.100" + +# Example with different severity levels +../otel-cli log \ + --service "otel-cli-demo" \ + --body "This is a warning message" \ + --severity WARN \ + --attrs "component=auth,issue=rate_limit" + +../otel-cli log \ + --service "otel-cli-demo" \ + --body "Critical error occurred" \ + --severity ERROR \ + --attrs "component=database,error=connection_timeout" diff --git a/go.mod b/go.mod index 1493b5a..0a50a91 100644 --- a/go.mod +++ b/go.mod @@ -1,19 +1,17 @@ module github.com/tobert/otel-cli -go 1.21 - -toolchain go1.22.4 +go 1.23.0 require ( - github.com/google/go-cmp v0.6.0 + github.com/google/go-cmp v0.7.0 github.com/pterm/pterm v0.12.79 github.com/spf13/cobra v1.8.0 - go.opentelemetry.io/otel v1.27.0 - go.opentelemetry.io/otel/sdk v1.27.0 - go.opentelemetry.io/proto/otlp v1.1.0 - google.golang.org/genproto/googleapis/rpc v0.0.0-20240610135401-a8a62080eff3 - google.golang.org/grpc v1.64.0 - google.golang.org/protobuf v1.34.2 + go.opentelemetry.io/otel v1.37.0 + go.opentelemetry.io/otel/sdk v1.37.0 + go.opentelemetry.io/proto/otlp v1.9.0 + google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 + google.golang.org/grpc v1.75.1 + google.golang.org/protobuf v1.36.10 ) require ( @@ -21,21 +19,23 @@ require ( atomicgo.dev/keyboard v0.2.9 // indirect atomicgo.dev/schedule v0.1.0 // indirect github.com/containerd/console v1.0.3 // indirect - github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gookit/color v1.5.4 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/rivo/uniseg v0.4.4 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - go.opentelemetry.io/otel/metric v1.27.0 // indirect - go.opentelemetry.io/otel/trace v1.27.0 // indirect - golang.org/x/net v0.22.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/term v0.18.0 // indirect - golang.org/x/text v0.14.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + golang.org/x/net v0.43.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/term v0.34.0 // indirect + golang.org/x/text v0.28.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect ) diff --git a/go.sum b/go.sum index f04039f..d63f6f3 100644 --- a/go.sum +++ b/go.sum @@ -23,18 +23,22 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs 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.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= -github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/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/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0 h1:Wqo399gCIufwto+VfwCSvsnfGpF/w5E9CNxSwbpD6No= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.0/go.mod h1:qmOFXW2epJhM0qSnUUYpldc7gVz2KMQwJ/QYCDIa7XU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -75,22 +79,26 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -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= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.opentelemetry.io/otel v1.27.0 h1:9BZoF3yMK/O1AafMiQTVu0YDj5Ea4hPhxCs7sGva+cg= -go.opentelemetry.io/otel v1.27.0/go.mod h1:DMpAK8fzYRzs+bi3rS5REupisuqTheUlSZJ1WnZaPAQ= -go.opentelemetry.io/otel/metric v1.27.0 h1:hvj3vdEKyeCi4YaYfNjv2NUje8FqKqUY8IlF0FxV/ik= -go.opentelemetry.io/otel/metric v1.27.0/go.mod h1:mVFgmRlhljgBiuk/MP/oKylr4hs85GZAylncepAX/ak= -go.opentelemetry.io/otel/sdk v1.27.0 h1:mlk+/Y1gLPLn84U4tI8d3GNJmGT/eXe3ZuOXN9kTWmI= -go.opentelemetry.io/otel/sdk v1.27.0/go.mod h1:Ha9vbLwJE6W86YstIywK2xFfPjbWlCuwPtMkKdz/Y4A= -go.opentelemetry.io/otel/trace v1.27.0 h1:IqYb813p7cmbHk0a5y6pD5JPakbVfftRXABGt5/Rscw= -go.opentelemetry.io/otel/trace v1.27.0/go.mod h1:6RiD1hkAprV4/q+yd2ln1HG9GoPx39SuvvstaLBl+l4= -go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI= -go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= @@ -101,8 +109,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= -golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -116,35 +124,37 @@ golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= -golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237 h1:RFiFrvy37/mpSpdySBDrUdipW/dHwsRwh3J3+A9VgT4= -google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240610135401-a8a62080eff3 h1:9Xyg6I9IWQZhRVfCWjKK+l6kI0jHcPesVlMnT//aHNo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240610135401-a8a62080eff3/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= -google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= -google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/main_test.go b/main_test.go index 3c559c1..6f4c937 100644 --- a/main_test.go +++ b/main_test.go @@ -20,9 +20,10 @@ import ( "testing" "time" + "github.com/google/go-cmp/cmp" "github.com/tobert/otel-cli/otlpclient" "github.com/tobert/otel-cli/otlpserver" - "github.com/google/go-cmp/cmp" + logspb "go.opentelemetry.io/proto/otlp/logs/v1" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) @@ -398,6 +399,7 @@ func runOtelCli(t *testing.T, fixture Fixture) (string, Results) { // these channels need to be buffered or the callback will hang trying to send rcvSpan := make(chan *tracepb.Span, 100) // 100 spans is enough for anybody rcvEvents := make(chan []*tracepb.Span_Event, 100) + rcvLog := make(chan *logspb.LogRecord, 100) // 100 logs is enough for anybody // otlpserver calls this function for each span received cb := func(ctx context.Context, span *tracepb.Span, events []*tracepb.Span_Event, rss *tracepb.ResourceSpans, headers map[string]string, meta map[string]string) bool { @@ -414,6 +416,19 @@ func runOtelCli(t *testing.T, fixture Fixture) (string, Results) { return results.SpanCount >= fixture.Expect.SpanCount } + // otlpserver calls this function for each log record received + logCb := func(ctx context.Context, logRecord *logspb.LogRecord, rls *logspb.ResourceLogs, headers map[string]string, meta map[string]string) bool { + rcvLog <- logRecord + + results.ServerMeta = meta + results.ResourceLogs = rls + results.LogCount++ + results.Headers = headers + + // true tells the server we're done and it can exit its loop + return results.LogCount >= fixture.Expect.LogCount + } + // prepare TLS configuration if needed var tlsConf *tls.Config if fixture.Config.ServerTLSEnabled { @@ -433,6 +448,9 @@ func runOtelCli(t *testing.T, fixture Fixture) (string, Results) { } defer cs.Stop() + // set the log callback for log tests + cs.SetLogCallback(logCb) + serverTimeout := time.Duration(fixture.Config.TestTimeoutMs) * time.Millisecond if serverTimeout == time.Duration(0) { serverTimeout = defaultTestTimeout @@ -567,14 +585,15 @@ func runOtelCli(t *testing.T, fixture Fixture) (string, Results) { } } - // when no spans are expected, return without reading from the channels - if fixture.Expect.SpanCount == 0 { + // when no spans or logs are expected, return without reading from the channels + if fixture.Expect.SpanCount == 0 && fixture.Expect.LogCount == 0 { return endpoint, results } // grab the spans & events from the server off the channels it writes to remainingTimeout := serverTimeout - time.Since(started) var gatheredSpans int + var gatheredLogs int gather: for { select { @@ -593,6 +612,12 @@ gather: t.Logf("[%s] test gathered %d span(s)", fixture.Name, gatheredSpans) break gather } + case results.LogRecord = <-rcvLog: + gatheredLogs++ + if gatheredLogs == results.LogCount { + t.Logf("[%s] test gathered %d log(s)", fixture.Name, gatheredLogs) + break gather + } } } diff --git a/otelcli/config.go b/otelcli/config.go index edc073f..e4384d2 100644 --- a/otelcli/config.go +++ b/otelcli/config.go @@ -62,6 +62,8 @@ func DefaultConfig() Config { SpanEndTime: "now", EventName: "todo-generate-default-event-names", EventTime: "now", + LogBody: "", + LogSeverity: "INFO", CfgFile: "", Verbose: false, Fail: false, @@ -76,7 +78,8 @@ func DefaultConfig() Config { type Config struct { Endpoint string `json:"endpoint" env:"OTEL_EXPORTER_OTLP_ENDPOINT"` TracesEndpoint string `json:"traces_endpoint" env:"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"` - Protocol string `json:"protocol" env:"OTEL_EXPORTER_OTLP_PROTOCOL,OTEL_EXPORTER_OTLP_TRACES_PROTOCOL"` + LogsEndpoint string `json:"logs_endpoint" env:"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT"` + Protocol string `json:"protocol" env:"OTEL_EXPORTER_OTLP_PROTOCOL,OTEL_EXPORTER_OTLP_TRACES_PROTOCOL,OTEL_EXPORTER_OTLP_LOGS_PROTOCOL"` Timeout string `json:"timeout" env:"OTEL_EXPORTER_OTLP_TIMEOUT,OTEL_EXPORTER_OTLP_TRACES_TIMEOUT"` Headers map[string]string `json:"otlp_headers" env:"OTEL_EXPORTER_OTLP_HEADERS"` // TODO: needs json marshaler hook to mask tokens Insecure bool `json:"insecure" env:"OTEL_EXPORTER_OTLP_INSECURE"` @@ -120,6 +123,9 @@ type Config struct { EventName string `json:"event_name" env:""` EventTime string `json:"event_time" env:""` + LogBody string `json:"log_body" env:"OTEL_CLI_LOG_BODY"` + LogSeverity string `json:"log_severity" env:"OTEL_CLI_LOG_SEVERITY"` + CfgFile string `json:"config_file" env:"OTEL_CLI_CONFIG_FILE"` Verbose bool `json:"verbose" env:"OTEL_CLI_VERBOSE"` Fail bool `json:"fail" env:"OTEL_CLI_FAIL"` @@ -353,6 +359,67 @@ func (config Config) ParseEndpoint() (*url.URL, string) { return epUrl, source } +// ParseLogsEndpoint parses the logs endpoint from config, following OTel spec. +// Signal-specific endpoint (LogsEndpoint) takes precedence over general Endpoint. +// Follows the same logic as ParseEndpoint but uses /v1/logs path for HTTP. +// https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md#endpoint-urls-for-otlphttp +func (config Config) ParseLogsEndpoint() (*url.URL, string) { + var endpoint, source string + var epUrl *url.URL + var err error + + // signal-specific configs get precedence over general endpoint per OTel spec + if config.LogsEndpoint != "" { + endpoint = config.LogsEndpoint + source = "signal" + } else if config.Endpoint != "" { + endpoint = config.Endpoint + source = "general" + } else { + config.SoftFail("no endpoint configuration available") + } + + parts := strings.Split(endpoint, ":") + // bare hostname? can only be grpc, prepend + if len(parts) == 1 { + epUrl, err = url.Parse("grpc://" + endpoint + ":4317") + if err != nil { + config.SoftFail("error parsing (assumed) gRPC bare host address '%s': %s", endpoint, err) + } + } else if len(parts) > 1 { // could be URI or host:port + // actual URIs + // grpc:// is only an otel-cli thing, maybe should drop it? + if parts[0] == "grpc" || parts[0] == "http" || parts[0] == "https" { + epUrl, err = url.Parse(endpoint) + if err != nil { + config.SoftFail("error parsing provided %s URI '%s': %s", source, endpoint, err) + } + } else { + // gRPC host:port + epUrl, err = url.Parse("grpc://" + endpoint) + if err != nil { + config.SoftFail("error parsing (assumed) gRPC host:port address '%s': %s", endpoint, err) + } + } + } + + // Per spec, /v1/logs is the default, appended to any url passed + // to the general endpoint + if strings.HasPrefix(epUrl.Scheme, "http") && source != "signal" && !strings.HasSuffix(epUrl.Path, "/v1/logs") { + epUrl.Path = path.Join(epUrl.Path, "/v1/logs") + } + + Diag.EndpointSource = source + Diag.Endpoint = epUrl.String() + return epUrl, source +} + +// GetLogsEndpoint returns the parsed logs endpoint URL. +func (c Config) GetLogsEndpoint() *url.URL { + ep, _ := c.ParseLogsEndpoint() + return ep +} + // SoftLog only calls through to log if otel-cli was run with the --verbose flag. // TODO: does it make any sense to support %w? probably yes, can clean up some // diagnostics.Error touch points. @@ -538,6 +605,12 @@ func (c Config) WithTracesEndpoint(with string) Config { return c } +// WithLogsEndpoint returns the config with LogsEndpoint set to the provided value. +func (c Config) WithLogsEndpoint(with string) Config { + c.LogsEndpoint = with + return c +} + // WithProtocol returns the config with protocol set to the provided value. func (c Config) WithProtocol(with string) Config { c.Protocol = with diff --git a/otelcli/config_log.go b/otelcli/config_log.go new file mode 100644 index 0000000..f87f0f1 --- /dev/null +++ b/otelcli/config_log.go @@ -0,0 +1,29 @@ +package otelcli + +import ( + "github.com/tobert/otel-cli/otlpclient" + commonpb "go.opentelemetry.io/proto/otlp/common/v1" + logspb "go.opentelemetry.io/proto/otlp/logs/v1" +) + +// NewProtobufLogRecord creates a new log record and populates it with information +// from the config struct. +func (c Config) NewProtobufLogRecord() *logspb.LogRecord { + logRecord := otlpclient.NewProtobufLogRecord() + + // Set the log body + if c.LogBody != "" { + logRecord.Body = &commonpb.AnyValue{ + Value: &commonpb.AnyValue_StringValue{StringValue: c.LogBody}, + } + } + + // Set severity + logRecord.SeverityNumber = otlpclient.LogSeverityStringToInt(c.LogSeverity) + logRecord.SeverityText = c.LogSeverity + + // Add attributes from config + logRecord.Attributes = otlpclient.StringMapAttrsToProtobuf(c.Attributes) + + return logRecord +} diff --git a/otelcli/exec.go b/otelcli/exec.go index 0a9df63..d2590aa 100644 --- a/otelcli/exec.go +++ b/otelcli/exec.go @@ -10,9 +10,9 @@ import ( "strings" "time" + "github.com/spf13/cobra" "github.com/tobert/otel-cli/otlpclient" "github.com/tobert/otel-cli/w3c/traceparent" - "github.com/spf13/cobra" commonpb "go.opentelemetry.io/proto/otlp/common/v1" tracev1 "go.opentelemetry.io/proto/otlp/trace/v1" ) diff --git a/otelcli/log.go b/otelcli/log.go new file mode 100644 index 0000000..7531915 --- /dev/null +++ b/otelcli/log.go @@ -0,0 +1,68 @@ +package otelcli + +import ( + "context" + "time" + + "github.com/spf13/cobra" + "github.com/tobert/otel-cli/otlpclient" +) + +// logCmd represents the log command +func logCmd(config *Config) *cobra.Command { + cmd := cobra.Command{ + Use: "log", + Short: "create an OpenTelemetry log record and send it", + Long: `Create an OpenTelemetry log record as specified and send it along. + +Example: + otel-cli log \ + --service "my-application" \ + --body "User login successful" \ + --severity INFO \ + --attrs "user.id=123,action=login" +`, + Run: doLog, + } + + cmd.Flags().SortFlags = false + + addCommonParams(&cmd, config) + addServiceParams(&cmd, config) + addLogParams(&cmd, config) + addAttrParams(&cmd, config) + addClientParams(&cmd, config) + + return &cmd +} + +func doLog(cmd *cobra.Command, args []string) { + ctx := cmd.Context() + config := getConfig(ctx) + ctx, cancel := context.WithDeadline(ctx, time.Now().Add(config.GetTimeout())) + defer cancel() + + // Create logs-specific client + client := otlpclient.NewGrpcLogsClient(config) + ctx, err := client.Start(ctx) + config.SoftFailIfErr(err) + + // Create log record + logRecord := config.NewProtobufLogRecord() + + // Send log + ctx, err = client.UploadLogs(ctx, logRecord) + config.SoftFailIfErr(err) + + // Cleanup + _, err = client.Stop(ctx) + config.SoftFailIfErr(err) +} + +// addLogParams adds log-specific parameters to the command. +func addLogParams(cmd *cobra.Command, config *Config) { + defaults := DefaultConfig() + + cmd.Flags().StringVar(&config.LogBody, "body", defaults.LogBody, "log message body") + cmd.Flags().StringVar(&config.LogSeverity, "severity", defaults.LogSeverity, "log severity level (TRACE, DEBUG, INFO, WARN, ERROR, FATAL)") +} diff --git a/otelcli/root.go b/otelcli/root.go index 7b3830a..c5aae25 100644 --- a/otelcli/root.go +++ b/otelcli/root.go @@ -69,6 +69,7 @@ func createRootCmd(config *Config) *cobra.Command { // add all the subcommands to rootCmd rootCmd.AddCommand(spanCmd(config)) + rootCmd.AddCommand(logCmd(config)) rootCmd.AddCommand(execCmd(config)) rootCmd.AddCommand(statusCmd(config)) rootCmd.AddCommand(serverCmd(config)) @@ -101,6 +102,8 @@ func addCommonParams(cmd *cobra.Command, config *Config) { cmd.Flags().StringVar(&config.Endpoint, "endpoint", defaults.Endpoint, "host and port for the desired OTLP/gRPC or OTLP/HTTP endpoint (use http:// or https:// for OTLP/HTTP)") // --traces-endpoint sets the endpoint for the traces signal cmd.Flags().StringVar(&config.TracesEndpoint, "traces-endpoint", defaults.TracesEndpoint, "HTTP(s) URL for traces") + // --logs-endpoint sets the endpoint for the logs signal + cmd.Flags().StringVar(&config.LogsEndpoint, "logs-endpoint", defaults.LogsEndpoint, "HTTP(s) URL for logs") // --protocol allows setting the OTLP protocol instead of relying on auto-detection from URI cmd.Flags().StringVar(&config.Protocol, "protocol", defaults.Protocol, "desired OTLP protocol: grpc or http/protobuf") // --timeout a default timeout to use in all otel-cli operations (default 1s) @@ -142,13 +145,18 @@ func addClientParams(cmd *cobra.Command, config *Config) { cmd.Flags().BoolVarP(&config.TraceparentPrintExport, "tp-export", "p", defaults.TraceparentPrintExport, "same as --tp-print but it puts an 'export ' in front so it's more convinenient to source in scripts") } +// addServiceParams adds the --service flag which is common across all OTel signals. +func addServiceParams(cmd *cobra.Command, config *Config) { + defaults := DefaultConfig() + // --service / -s + cmd.Flags().StringVarP(&config.ServiceName, "service", "s", defaults.ServiceName, "set the name of the service/application") +} + func addSpanParams(cmd *cobra.Command, config *Config) { defaults := DefaultConfig() - // --name / -s + // --name / -n cmd.Flags().StringVarP(&config.SpanName, "name", "n", defaults.SpanName, "set the name of the span") - // --service / -n - cmd.Flags().StringVarP(&config.ServiceName, "service", "s", defaults.ServiceName, "set the name of the application sent on the traces") // --kind / -k cmd.Flags().StringVarP(&config.Kind, "kind", "k", defaults.Kind, "set the trace kind, e.g. internal, server, client, producer, consumer") @@ -157,6 +165,7 @@ func addSpanParams(cmd *cobra.Command, config *Config) { cmd.Flags().StringVar(&config.ForceSpanId, "force-span-id", defaults.ForceSpanId, "expert: force the span id to be the one provided in hex") cmd.Flags().StringVar(&config.ForceParentSpanId, "force-parent-span-id", defaults.ForceParentSpanId, "expert: force the parent span id to be the one provided in hex") + addServiceParams(cmd, config) addSpanStatusParams(cmd, config) } diff --git a/otelcli/server.go b/otelcli/server.go index 4461720..6d6fbae 100644 --- a/otelcli/server.go +++ b/otelcli/server.go @@ -3,8 +3,8 @@ package otelcli import ( "strings" - "github.com/tobert/otel-cli/otlpserver" "github.com/spf13/cobra" + "github.com/tobert/otel-cli/otlpserver" ) const defaultOtlpEndpoint = "grpc://localhost:4317" @@ -25,7 +25,7 @@ func serverCmd(config *Config) *cobra.Command { // runServer runs the server on either grpc or http and blocks until the server // stops or is killed. -func runServer(config Config, cb otlpserver.Callback, stop otlpserver.Stopper) { +func runServer(config Config, cb otlpserver.TraceCallback, stop otlpserver.Stopper) { // unlike the rest of otel-cli, server should default to localhost:4317 if config.Endpoint == "" { config.Endpoint = defaultOtlpEndpoint diff --git a/otelcli/server_json.go b/otelcli/server_json.go index 33ee229..0f15d73 100644 --- a/otelcli/server_json.go +++ b/otelcli/server_json.go @@ -10,8 +10,8 @@ import ( "strconv" "time" - "github.com/tobert/otel-cli/otlpserver" "github.com/spf13/cobra" + "github.com/tobert/otel-cli/otlpserver" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) diff --git a/otelcli/server_tui.go b/otelcli/server_tui.go index 838c57d..3355662 100644 --- a/otelcli/server_tui.go +++ b/otelcli/server_tui.go @@ -8,10 +8,10 @@ import ( "sort" "strconv" - "github.com/tobert/otel-cli/otlpclient" - "github.com/tobert/otel-cli/otlpserver" "github.com/pterm/pterm" "github.com/spf13/cobra" + "github.com/tobert/otel-cli/otlpclient" + "github.com/tobert/otel-cli/otlpserver" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) diff --git a/otelcli/span.go b/otelcli/span.go index 71040a7..159a6bb 100644 --- a/otelcli/span.go +++ b/otelcli/span.go @@ -5,8 +5,8 @@ import ( "os" "time" - "github.com/tobert/otel-cli/otlpclient" "github.com/spf13/cobra" + "github.com/tobert/otel-cli/otlpclient" ) // spanCmd represents the span command diff --git a/otelcli/span_background.go b/otelcli/span_background.go index b6bb8f6..3771fee 100644 --- a/otelcli/span_background.go +++ b/otelcli/span_background.go @@ -9,8 +9,8 @@ import ( "syscall" "time" - "github.com/tobert/otel-cli/otlpclient" "github.com/spf13/cobra" + "github.com/tobert/otel-cli/otlpclient" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) diff --git a/otelcli/span_end.go b/otelcli/span_end.go index b8feb06..9002ed7 100644 --- a/otelcli/span_end.go +++ b/otelcli/span_end.go @@ -3,8 +3,8 @@ package otelcli import ( "os" - "github.com/tobert/otel-cli/w3c/traceparent" "github.com/spf13/cobra" + "github.com/tobert/otel-cli/w3c/traceparent" ) // spanEndCmd represents the span event command diff --git a/otelcli/span_event.go b/otelcli/span_event.go index 2edd572..0938834 100644 --- a/otelcli/span_event.go +++ b/otelcli/span_event.go @@ -4,8 +4,8 @@ import ( "os" "time" - "github.com/tobert/otel-cli/w3c/traceparent" "github.com/spf13/cobra" + "github.com/tobert/otel-cli/w3c/traceparent" ) // spanEventCmd represents the span event command diff --git a/otelcli/status.go b/otelcli/status.go index 9f7de37..308320f 100644 --- a/otelcli/status.go +++ b/otelcli/status.go @@ -10,8 +10,8 @@ import ( "strings" "time" - "github.com/tobert/otel-cli/otlpclient" "github.com/spf13/cobra" + "github.com/tobert/otel-cli/otlpclient" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" ) diff --git a/otlpclient/otlp_client.go b/otlpclient/otlp_client.go index 71b0265..e1bcd11 100644 --- a/otlpclient/otlp_client.go +++ b/otlpclient/otlp_client.go @@ -7,6 +7,7 @@ import ( "crypto/tls" "fmt" "net/url" + "strconv" "time" "go.opentelemetry.io/otel/attribute" @@ -30,6 +31,7 @@ type OTLPConfig interface { GetTlsConfig() *tls.Config GetIsRecording() bool GetEndpoint() *url.URL + GetLogsEndpoint() *url.URL GetInsecure() bool GetTimeout() time.Duration GetHeaders() map[string]string @@ -231,3 +233,33 @@ func retry(ctx context.Context, _ OTLPConfig, fun retryFun) (context.Context, er // retrying until timeout. Set the middle wait arg to a time.Duration to // sleep a requested amount of time before next try type retryFun func(ctx context.Context) (ctxOut context.Context, keepGoing bool, wait time.Duration, err error) + +// StringMapAttrsToProtobuf takes a map of string:string, such as that from --attrs +// and returns them in an []*commonpb.KeyValue +func StringMapAttrsToProtobuf(attributes map[string]string) []*commonpb.KeyValue { + out := []*commonpb.KeyValue{} + + for k, v := range attributes { + av := new(commonpb.AnyValue) + + // try to parse as numbers, and fall through to string + if i, err := strconv.ParseInt(v, 0, 64); err == nil { + av.Value = &commonpb.AnyValue_IntValue{IntValue: i} + } else if f, err := strconv.ParseFloat(v, 64); err == nil { + av.Value = &commonpb.AnyValue_DoubleValue{DoubleValue: f} + } else if b, err := strconv.ParseBool(v); err == nil { + av.Value = &commonpb.AnyValue_BoolValue{BoolValue: b} + } else { + av.Value = &commonpb.AnyValue_StringValue{StringValue: v} + } + + akv := commonpb.KeyValue{ + Key: k, + Value: av, + } + + out = append(out, &akv) + } + + return out +} diff --git a/otlpclient/otlp_client_grpc_logs.go b/otlpclient/otlp_client_grpc_logs.go new file mode 100644 index 0000000..bdecbb1 --- /dev/null +++ b/otlpclient/otlp_client_grpc_logs.go @@ -0,0 +1,107 @@ +package otlpclient + +import ( + "context" + "fmt" + "time" + + semconv "go.opentelemetry.io/otel/semconv/v1.17.0" + collogspb "go.opentelemetry.io/proto/otlp/collector/logs/v1" + commonpb "go.opentelemetry.io/proto/otlp/common/v1" + logspb "go.opentelemetry.io/proto/otlp/logs/v1" + resourcepb "go.opentelemetry.io/proto/otlp/resource/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" +) + +// GrpcLogsClient holds the state for gRPC connections for logs. +type GrpcLogsClient struct { + conn *grpc.ClientConn + client collogspb.LogsServiceClient + config OTLPConfig +} + +// NewGrpcLogsClient returns a fresh GrpcLogsClient ready to Start. +func NewGrpcLogsClient(config OTLPConfig) *GrpcLogsClient { + c := GrpcLogsClient{config: config} + return &c +} + +// Start configures and starts the connection to the gRPC server in the background. +func (gc *GrpcLogsClient) Start(ctx context.Context) (context.Context, error) { + var err error + endpointURL := gc.config.GetLogsEndpoint() + host := endpointURL.Hostname() + if endpointURL.Port() != "" { + host = host + ":" + endpointURL.Port() + } + + grpcOpts := []grpc.DialOption{} + + if gc.config.GetInsecure() { + grpcOpts = append(grpcOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) + } else { + grpcOpts = append(grpcOpts, grpc.WithTransportCredentials(credentials.NewTLS(gc.config.GetTlsConfig()))) + } + + gc.conn, err = grpc.DialContext(ctx, host, grpcOpts...) + if err != nil { + return ctx, fmt.Errorf("could not connect to gRPC/OTLP logs endpoint: %w", err) + } + + gc.client = collogspb.NewLogsServiceClient(gc.conn) + + return ctx, nil +} + +// UploadLogs takes a list of protobuf log records and sends them out. +func (gc *GrpcLogsClient) UploadLogs(ctx context.Context, logRecord *logspb.LogRecord) (context.Context, error) { + // add headers onto the request + headers := gc.config.GetHeaders() + if len(headers) > 0 { + md := metadata.New(headers) + ctx = metadata.NewOutgoingContext(ctx, md) + } + + resourceAttrs, err := resourceAttributes(ctx, gc.config.GetServiceName()) + if err != nil { + return ctx, err + } + + rls := []*logspb.ResourceLogs{ + { + Resource: &resourcepb.Resource{ + Attributes: resourceAttrs, + }, + ScopeLogs: []*logspb.ScopeLogs{ + { + Scope: &commonpb.InstrumentationScope{ + Name: "github.com/tobert/otel-cli", + Version: gc.config.GetVersion(), + Attributes: []*commonpb.KeyValue{}, + DroppedAttributesCount: 0, + }, + LogRecords: []*logspb.LogRecord{logRecord}, + SchemaUrl: semconv.SchemaURL, + }, + }, + SchemaUrl: semconv.SchemaURL, + }, + } + + req := collogspb.ExportLogsServiceRequest{ResourceLogs: rls} + + _, err = gc.client.Export(ctx, &req) + if err != nil { + return SaveError(ctx, time.Now(), err) + } + + return ctx, nil +} + +// Stop closes the connection to the gRPC server. +func (gc *GrpcLogsClient) Stop(ctx context.Context) (context.Context, error) { + return ctx, gc.conn.Close() +} diff --git a/otlpclient/otlp_client_grpc.go b/otlpclient/otlp_client_grpc_traces.go similarity index 100% rename from otlpclient/otlp_client_grpc.go rename to otlpclient/otlp_client_grpc_traces.go diff --git a/otlpclient/otlp_client_grpc_test.go b/otlpclient/otlp_client_grpc_traces_test.go similarity index 100% rename from otlpclient/otlp_client_grpc_test.go rename to otlpclient/otlp_client_grpc_traces_test.go diff --git a/otlpclient/otlp_client_http.go b/otlpclient/otlp_client_http_traces.go similarity index 100% rename from otlpclient/otlp_client_http.go rename to otlpclient/otlp_client_http_traces.go diff --git a/otlpclient/otlp_client_http_test.go b/otlpclient/otlp_client_http_traces_test.go similarity index 100% rename from otlpclient/otlp_client_http_test.go rename to otlpclient/otlp_client_http_traces_test.go diff --git a/otlpclient/protobuf_log.go b/otlpclient/protobuf_log.go new file mode 100644 index 0000000..3781338 --- /dev/null +++ b/otlpclient/protobuf_log.go @@ -0,0 +1,72 @@ +package otlpclient + +// Implements just enough sugar on the OTel Protocol Buffers log definition +// to support otel-cli and no more. + +import ( + "time" + + commonpb "go.opentelemetry.io/proto/otlp/common/v1" + logspb "go.opentelemetry.io/proto/otlp/logs/v1" +) + +// NewProtobufLogRecord returns an initialized OpenTelemetry protobuf LogRecord. +func NewProtobufLogRecord() *logspb.LogRecord { + now := time.Now() + logRecord := logspb.LogRecord{ + TimeUnixNano: uint64(now.UnixNano()), + ObservedTimeUnixNano: uint64(now.UnixNano()), + SeverityNumber: logspb.SeverityNumber_SEVERITY_NUMBER_INFO, + SeverityText: "INFO", + Body: &commonpb.AnyValue{}, + Attributes: []*commonpb.KeyValue{}, + DroppedAttributesCount: 0, + Flags: 0, + TraceId: []byte{}, + SpanId: []byte{}, + } + + return &logRecord +} + +// LogSeverityStringToInt takes a supported string log severity and returns the otel +// constant for it. Returns default of INFO on no match. +func LogSeverityStringToInt(severity string) logspb.SeverityNumber { + switch severity { + case "TRACE", "trace": + return logspb.SeverityNumber_SEVERITY_NUMBER_TRACE + case "DEBUG", "debug": + return logspb.SeverityNumber_SEVERITY_NUMBER_DEBUG + case "INFO", "info": + return logspb.SeverityNumber_SEVERITY_NUMBER_INFO + case "WARN", "warn": + return logspb.SeverityNumber_SEVERITY_NUMBER_WARN + case "ERROR", "error": + return logspb.SeverityNumber_SEVERITY_NUMBER_ERROR + case "FATAL", "fatal": + return logspb.SeverityNumber_SEVERITY_NUMBER_FATAL + default: + return logspb.SeverityNumber_SEVERITY_NUMBER_INFO + } +} + +// LogSeverityIntToString takes an integer/constant protobuf log severity value +// and returns the string representation used in otel-cli. +func LogSeverityIntToString(severity logspb.SeverityNumber) string { + switch severity { + case logspb.SeverityNumber_SEVERITY_NUMBER_TRACE: + return "TRACE" + case logspb.SeverityNumber_SEVERITY_NUMBER_DEBUG: + return "DEBUG" + case logspb.SeverityNumber_SEVERITY_NUMBER_INFO: + return "INFO" + case logspb.SeverityNumber_SEVERITY_NUMBER_WARN: + return "WARN" + case logspb.SeverityNumber_SEVERITY_NUMBER_ERROR: + return "ERROR" + case logspb.SeverityNumber_SEVERITY_NUMBER_FATAL: + return "FATAL" + default: + return "INFO" + } +} diff --git a/otlpclient/protobuf_span.go b/otlpclient/protobuf_trace.go similarity index 89% rename from otlpclient/protobuf_span.go rename to otlpclient/protobuf_trace.go index 8d121a9..cae36e2 100644 --- a/otlpclient/protobuf_span.go +++ b/otlpclient/protobuf_trace.go @@ -156,35 +156,6 @@ func SpanStatusStringToInt(status string) tracepb.Status_StatusCode { } } -// StringMapAttrsToProtobuf takes a map of string:string, such as that from --attrs -// and returns them in an []*commonpb.KeyValue -func StringMapAttrsToProtobuf(attributes map[string]string) []*commonpb.KeyValue { - out := []*commonpb.KeyValue{} - - for k, v := range attributes { - av := new(commonpb.AnyValue) - - // try to parse as numbers, and fall through to string - if i, err := strconv.ParseInt(v, 0, 64); err == nil { - av.Value = &commonpb.AnyValue_IntValue{IntValue: i} - } else if f, err := strconv.ParseFloat(v, 64); err == nil { - av.Value = &commonpb.AnyValue_DoubleValue{DoubleValue: f} - } else if b, err := strconv.ParseBool(v); err == nil { - av.Value = &commonpb.AnyValue_BoolValue{BoolValue: b} - } else { - av.Value = &commonpb.AnyValue_StringValue{StringValue: v} - } - - akv := commonpb.KeyValue{ - Key: k, - Value: av, - } - - out = append(out, &akv) - } - - return out -} // SpanAttributesToStringMap converts the span's attributes to a string map. func SpanAttributesToStringMap(span *tracepb.Span) map[string]string { diff --git a/otlpclient/protobuf_span_test.go b/otlpclient/protobuf_trace_test.go similarity index 100% rename from otlpclient/protobuf_span_test.go rename to otlpclient/protobuf_trace_test.go diff --git a/otlpserver/grpcserver.go b/otlpserver/grpcserver.go index 9847585..ade4f75 100644 --- a/otlpserver/grpcserver.go +++ b/otlpserver/grpcserver.go @@ -8,51 +8,72 @@ import ( "net" "sync" + collogspb "go.opentelemetry.io/proto/otlp/collector/logs/v1" coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1" "google.golang.org/grpc" "google.golang.org/grpc/metadata" ) +// grpcServerState holds the shared state for all signal services. +type grpcServerState struct { + server *grpc.Server + traceCallback TraceCallback + logCallback LogCallback + stoponce sync.Once + stopper chan struct{} + stopdone chan struct{} + doneonce sync.Once +} + // GrpcServer is a gRPC/OTLP server handle. type GrpcServer struct { - server *grpc.Server - callback Callback - stoponce sync.Once - stopper chan struct{} - stopdone chan struct{} - doneonce sync.Once + state *grpcServerState coltracepb.UnimplementedTraceServiceServer } +// grpcLogsService handles logs exports. +type grpcLogsService struct { + state *grpcServerState + collogspb.UnimplementedLogsServiceServer +} + // NewGrpcServer takes a callback and stop function and returns a Server ready // to run with .Serve(). Optional grpc.ServerOption arguments can be provided // for TLS configuration and other server options. -func NewGrpcServer(cb Callback, stop Stopper, opts ...grpc.ServerOption) *GrpcServer { - s := GrpcServer{ - server: grpc.NewServer(opts...), - callback: cb, - stopper: make(chan struct{}), - stopdone: make(chan struct{}, 1), +func NewGrpcServer(cb TraceCallback, stop Stopper, opts ...grpc.ServerOption) *GrpcServer { + state := &grpcServerState{ + server: grpc.NewServer(opts...), + traceCallback: cb, + stopper: make(chan struct{}), + stopdone: make(chan struct{}, 1), } - coltracepb.RegisterTraceServiceServer(s.server, &s) + s := &GrpcServer{state: state} + + coltracepb.RegisterTraceServiceServer(state.server, s) + collogspb.RegisterLogsServiceServer(state.server, &grpcLogsService{state: state}) // single place to stop the server, used by timeout and max-spans go func() { - <-s.stopper - stop(&s) - s.server.GracefulStop() + <-state.stopper + stop(s) + state.server.GracefulStop() }() - return &s + return s +} + +// SetLogCallback sets the log callback for the server. +func (gs *GrpcServer) SetLogCallback(cb LogCallback) { + gs.state.logCallback = cb } // ServeGRPC takes a listener and starts the GRPC server on that listener. // Blocks until Stop() is called. func (gs *GrpcServer) Serve(listener net.Listener) error { - err := gs.server.Serve(listener) - gs.stopdone <- struct{}{} + err := gs.state.server.Serve(listener) + gs.state.stopdone <- struct{}{} return err } @@ -71,20 +92,20 @@ func (gs *GrpcServer) ListenAndServe(otlpEndpoint string) { // Stop sends a value to the server shutdown goroutine so it stops GRPC // and calls the stop function given to newServer. Safe to call multiple times. func (gs *GrpcServer) Stop() { - gs.stoponce.Do(func() { - gs.stopper <- struct{}{} + gs.state.stoponce.Do(func() { + gs.state.stopper <- struct{}{} }) } // StopWait stops the server and waits for it to affirm shutdown. func (gs *GrpcServer) StopWait() { gs.Stop() - gs.doneonce.Do(func() { - <-gs.stopdone + gs.state.doneonce.Do(func() { + <-gs.state.stopdone }) } -// Export implements the gRPC server interface for exporting messages. +// Export implements the gRPC server interface for exporting trace messages. func (gs *GrpcServer) Export(ctx context.Context, req *coltracepb.ExportTraceServiceRequest) (*coltracepb.ExportTraceServiceResponse, error) { // OTLP/gRPC headers are passed in metadata, copy them to serverMeta // for now. This isn't ideal but gets them exposed to the test suite. @@ -98,9 +119,37 @@ func (gs *GrpcServer) Export(ctx context.Context, req *coltracepb.ExportTraceSer } } - done := doCallback(ctx, gs.callback, req, headers, map[string]string{"proto": "grpc"}) + done := doCallback(ctx, gs.state.traceCallback, req, headers, map[string]string{"proto": "grpc"}) if done { go gs.StopWait() } return &coltracepb.ExportTraceServiceResponse{}, nil } + +// Export implements the gRPC server interface for exporting log records. +func (ls *grpcLogsService) Export(ctx context.Context, req *collogspb.ExportLogsServiceRequest) (*collogspb.ExportLogsServiceResponse, error) { + // only process if we have a log callback set + if ls.state.logCallback == nil { + return &collogspb.ExportLogsServiceResponse{}, nil + } + + // OTLP/gRPC headers are passed in metadata, copy them to serverMeta + headers := make(map[string]string) + if md, ok := metadata.FromIncomingContext(ctx); ok { + for mdk := range md { + vals := md.Get(mdk) + buf := bytes.NewBuffer([]byte{}) + csv.NewWriter(buf).WriteAll([][]string{vals}) + headers[mdk] = buf.String() + } + } + + done := doLogCallback(ctx, ls.state.logCallback, req, headers, map[string]string{"proto": "grpc"}) + if done { + // need to call StopWait on the GrpcServer, not directly on state + // so we create a temporary GrpcServer wrapper + gs := &GrpcServer{state: ls.state} + go gs.StopWait() + } + return &collogspb.ExportLogsServiceResponse{}, nil +} diff --git a/otlpserver/httpserver.go b/otlpserver/httpserver.go index dde7861..7310b0f 100644 --- a/otlpserver/httpserver.go +++ b/otlpserver/httpserver.go @@ -8,22 +8,24 @@ import ( "net" "net/http" + collogspb "go.opentelemetry.io/proto/otlp/collector/logs/v1" coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1" "google.golang.org/protobuf/proto" ) // HttpServer is a handle for otlp over http/protobuf. type HttpServer struct { - server *http.Server - callback Callback + server *http.Server + traceCallback TraceCallback + logCallback LogCallback } // NewServer takes a callback and stop function and returns a Server ready // to run with .Serve(). -func NewHttpServer(cb Callback, stop Stopper) *HttpServer { +func NewHttpServer(cb TraceCallback, stop Stopper) *HttpServer { s := HttpServer{ - server: &http.Server{}, - callback: cb, + server: &http.Server{}, + traceCallback: cb, } s.server.Handler = &s @@ -31,9 +33,24 @@ func NewHttpServer(cb Callback, stop Stopper) *HttpServer { return &s } -// ServeHTTP processes every request as if it is a trace regardless of -// method and path or anything else. +// ServeHTTP routes requests to the appropriate handler based on URL path. +// Routes /v1/traces to trace handler and /v1/logs to log handler per OTLP spec. +// For backwards compatibility, all other paths are treated as trace endpoints. func (hs *HttpServer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { + // Route based on OTLP specification paths + switch req.RequestURI { + case "/v1/traces": + hs.handleTraces(rw, req) + case "/v1/logs": + hs.handleLogs(rw, req) + default: + // For backwards compatibility, treat unspecified paths as traces + hs.handleTraces(rw, req) + } +} + +// handleTraces processes trace export requests. +func (hs *HttpServer) handleTraces(rw http.ResponseWriter, req *http.Request) { data, err := io.ReadAll(req.Body) if err != nil { log.Fatalf("Error while reading request body: %s", err) @@ -47,6 +64,49 @@ func (hs *HttpServer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { json.Unmarshal(data, &msg) default: rw.WriteHeader(http.StatusNotAcceptable) + return + } + + meta := map[string]string{ + "method": req.Method, + "proto": req.Proto, + "content-type": req.Header.Get("Content-Type"), + "host": req.Host, + "uri": req.RequestURI, + } + + headers := make(map[string]string) + for k := range req.Header { + headers[k] = req.Header.Get(k) + } + + done := doCallback(req.Context(), hs.traceCallback, &msg, headers, meta) + if done { + go hs.StopWait() + } +} + +// handleLogs processes log export requests. +func (hs *HttpServer) handleLogs(rw http.ResponseWriter, req *http.Request) { + if hs.logCallback == nil { + rw.WriteHeader(http.StatusOK) + return + } + + data, err := io.ReadAll(req.Body) + if err != nil { + log.Fatalf("Error while reading request body: %s", err) + } + + msg := collogspb.ExportLogsServiceRequest{} + switch req.Header.Get("Content-Type") { + case "application/x-protobuf": + proto.Unmarshal(data, &msg) + case "application/json": + json.Unmarshal(data, &msg) + default: + rw.WriteHeader(http.StatusNotAcceptable) + return } meta := map[string]string{ @@ -62,7 +122,7 @@ func (hs *HttpServer) ServeHTTP(rw http.ResponseWriter, req *http.Request) { headers[k] = req.Header.Get(k) } - done := doCallback(req.Context(), hs.callback, &msg, headers, meta) + done := doLogCallback(req.Context(), hs.logCallback, &msg, headers, meta) if done { go hs.StopWait() } @@ -96,3 +156,8 @@ func (hs *HttpServer) Stop() { func (hs *HttpServer) StopWait() { hs.server.Shutdown(context.Background()) } + +// SetLogCallback sets the log callback for the server. +func (hs *HttpServer) SetLogCallback(cb LogCallback) { + hs.logCallback = cb +} diff --git a/otlpserver/server.go b/otlpserver/server.go index 9537e99..9fbb85e 100644 --- a/otlpserver/server.go +++ b/otlpserver/server.go @@ -9,15 +9,20 @@ import ( "crypto/tls" "net" - colv1 "go.opentelemetry.io/proto/otlp/collector/trace/v1" + collogspb "go.opentelemetry.io/proto/otlp/collector/logs/v1" + coltracepb "go.opentelemetry.io/proto/otlp/collector/trace/v1" + logspb "go.opentelemetry.io/proto/otlp/logs/v1" tracepb "go.opentelemetry.io/proto/otlp/trace/v1" "google.golang.org/grpc" "google.golang.org/grpc/credentials" ) -// Callback is a type for the function passed to newServer that is +// TraceCallback is a type for the function passed to newServer that is // called for each incoming span. -type Callback func(context.Context, *tracepb.Span, []*tracepb.Span_Event, *tracepb.ResourceSpans, map[string]string, map[string]string) bool +type TraceCallback func(context.Context, *tracepb.Span, []*tracepb.Span_Event, *tracepb.ResourceSpans, map[string]string, map[string]string) bool + +// LogCallback is a type for the function called for each incoming log record. +type LogCallback func(context.Context, *logspb.LogRecord, *logspb.ResourceLogs, map[string]string, map[string]string) bool // Stopper is the function passed to newServer to be called when the // server is shut down. @@ -30,11 +35,12 @@ type OtlpServer interface { Serve(listener net.Listener) error Stop() StopWait() + SetLogCallback(LogCallback) } // NewServer will start the requested server protocol, one of grpc, http/protobuf, // and http/json. Optional TLS configuration can be provided for gRPC servers. -func NewServer(protocol string, cb Callback, stop Stopper, tlsConf ...*tls.Config) OtlpServer { +func NewServer(protocol string, cb TraceCallback, stop Stopper, tlsConf ...*tls.Config) OtlpServer { switch protocol { case "grpc": // if TLS config is provided, convert to gRPC credentials @@ -53,7 +59,7 @@ func NewServer(protocol string, cb Callback, stop Stopper, tlsConf ...*tls.Confi // doCallback unwraps the OTLP service request and calls the callback // for each span in the request. -func doCallback(ctx context.Context, cb Callback, req *colv1.ExportTraceServiceRequest, headers map[string]string, serverMeta map[string]string) bool { +func doCallback(ctx context.Context, cb TraceCallback, req *coltracepb.ExportTraceServiceRequest, headers map[string]string, serverMeta map[string]string) bool { rss := req.GetResourceSpans() for _, resource := range rss { scopeSpans := resource.GetScopeSpans() @@ -74,3 +80,22 @@ func doCallback(ctx context.Context, cb Callback, req *colv1.ExportTraceServiceR return false } + +// doLogCallback unwraps the OTLP logs service request and calls the callback +// for each log record in the request. +func doLogCallback(ctx context.Context, cb LogCallback, req *collogspb.ExportLogsServiceRequest, headers map[string]string, serverMeta map[string]string) bool { + rls := req.GetResourceLogs() + for _, resource := range rls { + scopeLogs := resource.GetScopeLogs() + for _, sl := range scopeLogs { + for _, logRecord := range sl.GetLogRecords() { + done := cb(ctx, logRecord, resource, headers, serverMeta) + if done { + return true + } + } + } + } + + return false +}