Added the files for this package.
This commit is contained in:
parent
4424b06532
commit
d56374a8f7
32 changed files with 2179 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/.idea/
|
208
config.go
Normal file
208
config.go
Normal file
|
@ -0,0 +1,208 @@
|
||||||
|
package otelchi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
otelmetric "go.opentelemetry.io/otel/metric"
|
||||||
|
"go.opentelemetry.io/otel/propagation"
|
||||||
|
oteltrace "go.opentelemetry.io/otel/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These defaults are used in `TraceHeaderConfig`.
|
||||||
|
const (
|
||||||
|
DefaultTraceIDResponseHeaderKey = "X-Trace-Id"
|
||||||
|
DefaultTraceSampledResponseHeaderKey = "X-Trace-Sampled"
|
||||||
|
)
|
||||||
|
|
||||||
|
// config is used to configure the mux middleware.
|
||||||
|
type config struct {
|
||||||
|
TracerProvider oteltrace.TracerProvider
|
||||||
|
MeterProvider otelmetric.MeterProvider
|
||||||
|
Propagators propagation.TextMapPropagator
|
||||||
|
ChiRoutes chi.Routes
|
||||||
|
RequestMethodInSpanName bool
|
||||||
|
Filters []Filter
|
||||||
|
TraceIDResponseHeaderKey string
|
||||||
|
TraceSampledResponseHeaderKey string
|
||||||
|
PublicEndpointFn func(r *http.Request) bool
|
||||||
|
|
||||||
|
DisableMeasureInflight bool
|
||||||
|
DisableMeasureSize bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option specifies instrumentation configuration options.
|
||||||
|
type Option interface {
|
||||||
|
apply(*config)
|
||||||
|
}
|
||||||
|
|
||||||
|
type optionFunc func(*config)
|
||||||
|
|
||||||
|
func (o optionFunc) apply(c *config) {
|
||||||
|
o(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter is a predicate used to determine whether a given http.Request should
|
||||||
|
// be traced. A Filter must return true if the request should be traced.
|
||||||
|
type Filter func(*http.Request) bool
|
||||||
|
|
||||||
|
// WithPropagators specifies propagators to use for extracting
|
||||||
|
// information from the HTTP requests. If none are specified, global
|
||||||
|
// ones will be used.
|
||||||
|
func WithPropagators(propagators propagation.TextMapPropagator) Option {
|
||||||
|
return optionFunc(func(cfg *config) {
|
||||||
|
cfg.Propagators = propagators
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTracerProvider specifies a tracer provider to use for creating a tracer.
|
||||||
|
// If none is specified, the global provider is used.
|
||||||
|
func WithTracerProvider(provider oteltrace.TracerProvider) Option {
|
||||||
|
return optionFunc(func(cfg *config) {
|
||||||
|
cfg.TracerProvider = provider
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMeterProvider specifies a meter provider to use for creating a meter.
|
||||||
|
// If none is specified, the global provider is used.
|
||||||
|
func WithMeterProvider(provider otelmetric.MeterProvider) Option {
|
||||||
|
return optionFunc(func(cfg *config) {
|
||||||
|
cfg.MeterProvider = provider
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMeasureInflightDisabled specifies whether to measure the number of inflight requests.
|
||||||
|
// If this option is not set, the number of inflight requests will be measured.
|
||||||
|
func WithMeasureInflightDisabled(isDisabled bool) Option {
|
||||||
|
return optionFunc(func(cfg *config) {
|
||||||
|
cfg.DisableMeasureInflight = isDisabled
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMeasureSizeDisabled specifies whether to measure the size of the response body.
|
||||||
|
// If this option is not set, the size of the response body will be measured.
|
||||||
|
func WithMeasureSizeDisabled(isDisabled bool) Option {
|
||||||
|
return optionFunc(func(cfg *config) {
|
||||||
|
cfg.DisableMeasureSize = isDisabled
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithChiRoutes specified the routes that being used by application. Its main
|
||||||
|
// purpose is to provide route pattern as span name during span creation. If this
|
||||||
|
// option is not set, by default the span will be given name at the end of span
|
||||||
|
// execution. For some people, this behavior is not desirable since they want
|
||||||
|
// to override the span name on underlying handler. By setting this option, it
|
||||||
|
// is possible for them to override the span name.
|
||||||
|
func WithChiRoutes(routes chi.Routes) Option {
|
||||||
|
return optionFunc(func(cfg *config) {
|
||||||
|
cfg.ChiRoutes = routes
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithRequestMethodInSpanName is used for adding http request method to span name.
|
||||||
|
// While this is not necessary for vendors that properly implemented the tracing
|
||||||
|
// specs (e.g Jaeger, AWS X-Ray, etc...), but for other vendors such as Elastic
|
||||||
|
// and New Relic this might be helpful.
|
||||||
|
//
|
||||||
|
// See following threads for details:
|
||||||
|
//
|
||||||
|
// - https://github.com/riandyrn/otelchi/pull/3#issuecomment-1005883910
|
||||||
|
// - https://github.com/riandyrn/otelchi/issues/6#issuecomment-1034461912
|
||||||
|
func WithRequestMethodInSpanName(isActive bool) Option {
|
||||||
|
return optionFunc(func(cfg *config) {
|
||||||
|
cfg.RequestMethodInSpanName = isActive
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithFilter adds a filter to the list of filters used by the handler.
|
||||||
|
// If any filter indicates to exclude a request then the request will not be
|
||||||
|
// traced. All filters must allow a request to be traced for a Span to be created.
|
||||||
|
// If no filters are provided then all requests are traced.
|
||||||
|
// Filters will be invoked for each processed request, it is advised to make them
|
||||||
|
// simple and fast.
|
||||||
|
func WithFilter(filter Filter) Option {
|
||||||
|
return optionFunc(func(cfg *config) {
|
||||||
|
cfg.Filters = append(cfg.Filters, filter)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTraceIDResponseHeader enables adding trace id into response header.
|
||||||
|
// It accepts a function that generates the header key name. If this parameter
|
||||||
|
// function set to `nil` the default header key which is `X-Trace-Id` will be used.
|
||||||
|
//
|
||||||
|
// Deprecated: use `WithTraceResponseHeaders` instead.
|
||||||
|
func WithTraceIDResponseHeader(headerKeyFunc func() string) Option {
|
||||||
|
cfg := TraceHeaderConfig{
|
||||||
|
TraceIDHeader: "",
|
||||||
|
TraceSampledHeader: "",
|
||||||
|
}
|
||||||
|
if headerKeyFunc != nil {
|
||||||
|
cfg.TraceIDHeader = headerKeyFunc()
|
||||||
|
}
|
||||||
|
return WithTraceResponseHeaders(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TraceHeaderConfig is configuration for trace headers in the response.
|
||||||
|
type TraceHeaderConfig struct {
|
||||||
|
TraceIDHeader string // if non-empty overrides the default of X-Trace-ID
|
||||||
|
TraceSampledHeader string // if non-empty overrides the default of X-Trace-Sampled
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTraceResponseHeaders configures the response headers for trace information.
|
||||||
|
// It accepts a TraceHeaderConfig struct that contains the keys for the Trace ID
|
||||||
|
// and Trace Sampled headers. If the provided keys are empty, default values will
|
||||||
|
// be used for the respective headers.
|
||||||
|
func WithTraceResponseHeaders(cfg TraceHeaderConfig) Option {
|
||||||
|
return optionFunc(func(c *config) {
|
||||||
|
c.TraceIDResponseHeaderKey = cfg.TraceIDHeader
|
||||||
|
if c.TraceIDResponseHeaderKey == "" {
|
||||||
|
c.TraceIDResponseHeaderKey = DefaultTraceIDResponseHeaderKey
|
||||||
|
}
|
||||||
|
|
||||||
|
c.TraceSampledResponseHeaderKey = cfg.TraceSampledHeader
|
||||||
|
if c.TraceSampledResponseHeaderKey == "" {
|
||||||
|
c.TraceSampledResponseHeaderKey = DefaultTraceSampledResponseHeaderKey
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPublicEndpoint is used for marking every endpoint as public endpoint.
|
||||||
|
// This means if the incoming request has span context, it won't be used as
|
||||||
|
// parent span by the span generated by this middleware, instead the generated
|
||||||
|
// span will be the root span (new trace) and then linked to the span from the
|
||||||
|
// incoming request.
|
||||||
|
//
|
||||||
|
// Let say we have the following scenario:
|
||||||
|
//
|
||||||
|
// 1. We have 2 systems: `SysA` & `SysB`.
|
||||||
|
// 2. `SysA` has the following services: `SvcA.1` & `SvcA.2`.
|
||||||
|
// 3. `SysB` has the following services: `SvcB.1` & `SvcB.2`.
|
||||||
|
// 4. `SvcA.2` is used internally only by `SvcA.1`.
|
||||||
|
// 5. `SvcB.2` is used internally only by `SvcB.1`.
|
||||||
|
// 6. All of these services already instrumented otelchi & using the same collector (e.g Jaeger).
|
||||||
|
// 7. In `SvcA.1` we should set `WithPublicEndpoint()` since it is the entry point (a.k.a "public endpoint") for entering `SysA`.
|
||||||
|
// 8. In `SvcA.2` we should not set `WithPublicEndpoint()` since it is only used internally by `SvcA.1` inside `SysA`.
|
||||||
|
// 9. Point 7 & 8 also applies to both services in `SysB`.
|
||||||
|
//
|
||||||
|
// Now, whenever `SvcA.1` calls `SvcA.2` there will be only a single trace generated. This trace will contain 2 spans: root span from `SvcA.1` & child span from `SvcA.2`.
|
||||||
|
//
|
||||||
|
// But if let say `SvcA.2` calls `SvcB.1`, then there will be 2 traces generated: trace from `SysA` & trace from `SysB`. But in trace generated in `SysB` there will be like a marking that this trace is actually related to trace in `SysA` (a.k.a linked with the trace from `SysA`).
|
||||||
|
func WithPublicEndpoint() Option {
|
||||||
|
return WithPublicEndpointFn(func(r *http.Request) bool { return true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPublicEndpointFn runs with every request, and allows conditionally
|
||||||
|
// configuring the Handler to link the generated span with an incoming span
|
||||||
|
// context.
|
||||||
|
//
|
||||||
|
// If the function return `true` the generated span will be linked with the
|
||||||
|
// incoming span context. Otherwise, the generated span will be set as the
|
||||||
|
// child span of the incoming span context.
|
||||||
|
//
|
||||||
|
// Essentially it has the same functionality as `WithPublicEndpoint` but with
|
||||||
|
// more flexibility.
|
||||||
|
func WithPublicEndpointFn(fn func(r *http.Request) bool) Option {
|
||||||
|
return optionFunc(func(cfg *config) {
|
||||||
|
cfg.PublicEndpointFn = fn
|
||||||
|
})
|
||||||
|
}
|
7
examples/basic/Dockerfile
Normal file
7
examples/basic/Dockerfile
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
FROM golang:1.22-alpine3.20
|
||||||
|
|
||||||
|
WORKDIR /go/src/toastielab.dev/toastie-stuff/otelchi/examples/basic
|
||||||
|
RUN go mod download -x
|
||||||
|
RUN go build -o server
|
||||||
|
|
||||||
|
ENTRYPOINT ["./server"]
|
6
examples/basic/Makefile
Normal file
6
examples/basic/Makefile
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
run:
|
||||||
|
-make down
|
||||||
|
docker-compose up --build
|
||||||
|
|
||||||
|
down:
|
||||||
|
docker-compose down --remove-orphans
|
25
examples/basic/README.md
Normal file
25
examples/basic/README.md
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
# go-chi/chi instrumentation example
|
||||||
|
|
||||||
|
An HTTP server using go-chi/chi and instrumentation. The server has a `/users/{id:[0-9]+}` endpoint. The server generates span information to `stdout`.
|
||||||
|
|
||||||
|
These instructions expect you to have [docker-compose](https://docs.docker.com/compose/) installed.
|
||||||
|
|
||||||
|
Bring up the `mux-server` and `mux-client` services to run the
|
||||||
|
example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker-compose up --detach mux-server mux-client
|
||||||
|
```
|
||||||
|
|
||||||
|
The `mux-client` service sends just one HTTP request to the `mux-server`
|
||||||
|
and then exits. View the span generated by the `mux-server` in the logs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker-compose logs mux-server
|
||||||
|
```
|
||||||
|
|
||||||
|
Shut down the services when you are finished with the example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker-compose down
|
||||||
|
```
|
18
examples/basic/docker-compose.yml
Normal file
18
examples/basic/docker-compose.yml
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
version: "2.4"
|
||||||
|
services:
|
||||||
|
mux-client:
|
||||||
|
image: curlimages/curl:7.84.0
|
||||||
|
command: "curl -XGET http://mux-server:8080/users/123"
|
||||||
|
depends_on:
|
||||||
|
- mux-server
|
||||||
|
mux-server:
|
||||||
|
build:
|
||||||
|
# we set the build context to toastielab.dev/toastie-stuff/otelchi because we use relative
|
||||||
|
# path in go.mod for otelchi library
|
||||||
|
context: ../../
|
||||||
|
|
||||||
|
# since we are on toastielab.dev/toastie-stuff/otelchi, the dockerfile location would be
|
||||||
|
# in ./examples/basic/Dockerfile
|
||||||
|
dockerfile: ./examples/basic/Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
25
examples/basic/go.mod
Normal file
25
examples/basic/go.mod
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
module toastielab.dev/toastie-stuff/otelchi/examples/basic
|
||||||
|
|
||||||
|
go 1.22.0
|
||||||
|
|
||||||
|
replace toastielab.dev/toastie-stuff/otelchi => ../../
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0
|
||||||
|
toastielab.dev/toastie-stuff/otelchi v0.10.0
|
||||||
|
go.opentelemetry.io/otel v1.30.0
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.30.0
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.30.0
|
||||||
|
go.opentelemetry.io/otel/sdk v1.30.0
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.30.0
|
||||||
|
go.opentelemetry.io/otel/trace v1.30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.2 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.30.0 // indirect
|
||||||
|
golang.org/x/sys v0.25.0 // indirect
|
||||||
|
)
|
15
examples/basic/go.sum
Normal file
15
examples/basic/go.sum
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/riandyrn/otelchi v0.10.0/go.mod h1:zBaX2FavWMlsvq4GqHit+QXxF1c5wIMZZFaYyW4+7FA=
|
||||||
|
go.opentelemetry.io/otel v1.30.0/go.mod h1:tFw4Br9b7fOS+uEao81PJjVMjW/5fvNCbpsDIXqP0pc=
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.30.0/go.mod h1:bxiX8eUeKoAEQmbq/ecUT8UqZwCjZW52yJrXJUSozsk=
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.30.0/go.mod h1:ljkUDtAMdleoi9tIG1R6dJUpVwDcYjw3J2Q6Q/SuiC0=
|
||||||
|
go.opentelemetry.io/otel/metric v1.30.0/go.mod h1:aXTfST94tswhWEb+5QjlSqG+cZlmyXy/u8jFpor3WqQ=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.30.0/go.mod h1:p14X4Ok8S+sygzblytT1nqG98QG2KYKv++HE0LY/mhg=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.30.0/go.mod h1:waS6P3YqFNzeP01kuo/MBBYqaoBJl7efRQHOaydhy1Y=
|
||||||
|
go.opentelemetry.io/otel/trace v1.30.0/go.mod h1:5EyKqTzzmyqB9bwtCCq6pDLktPK6fmGf/Dph+8VI02o=
|
||||||
|
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
99
examples/basic/main.go
Normal file
99
examples/basic/main.go
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
"go.opentelemetry.io/otel/exporters/stdout/stdoutmetric"
|
||||||
|
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
|
||||||
|
"go.opentelemetry.io/otel/propagation"
|
||||||
|
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||||
|
"go.opentelemetry.io/otel/sdk/resource"
|
||||||
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||||
|
semconv "go.opentelemetry.io/otel/semconv/v1.20.0"
|
||||||
|
oteltrace "go.opentelemetry.io/otel/trace"
|
||||||
|
|
||||||
|
"toastielab.dev/toastie-stuff/otelchi"
|
||||||
|
)
|
||||||
|
|
||||||
|
var tracer oteltrace.Tracer
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// initialize trace provider
|
||||||
|
tp, mp := initOtelProviders()
|
||||||
|
defer func() {
|
||||||
|
if err := tp.Shutdown(context.Background()); err != nil {
|
||||||
|
log.Printf("Error shutting down tracer provider: %v", err)
|
||||||
|
}
|
||||||
|
if err := mp.Shutdown(context.Background()); err != nil {
|
||||||
|
log.Printf("Error shutting down meter provider: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// set global tracer provider & text propagators
|
||||||
|
otel.SetTracerProvider(tp)
|
||||||
|
otel.SetMeterProvider(mp)
|
||||||
|
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}))
|
||||||
|
// initialize tracer
|
||||||
|
tracer = otel.Tracer("mux-server")
|
||||||
|
|
||||||
|
// define router
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(otelchi.Middleware("my-server", otelchi.WithChiRoutes(r)))
|
||||||
|
r.HandleFunc("/users/{id:[0-9]+}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
name := getUser(r.Context(), id)
|
||||||
|
reply := fmt.Sprintf("user %s (id %s)\n", name, id)
|
||||||
|
w.Write(([]byte)(reply))
|
||||||
|
})
|
||||||
|
|
||||||
|
// serve router
|
||||||
|
_ = http.ListenAndServe(":8080", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func initOtelProviders() (*sdktrace.TracerProvider, *sdkmetric.MeterProvider) {
|
||||||
|
traceExporter, err := stdouttrace.New(stdouttrace.WithPrettyPrint())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metricExporter, err := stdoutmetric.New(stdoutmetric.WithPrettyPrint())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := resource.New(
|
||||||
|
context.Background(),
|
||||||
|
resource.WithAttributes(
|
||||||
|
// the service name used to display traces in backends
|
||||||
|
semconv.ServiceNameKey.String("mux-server"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("unable to initialize resource due: %v", err)
|
||||||
|
}
|
||||||
|
return sdktrace.NewTracerProvider(
|
||||||
|
sdktrace.WithSampler(sdktrace.AlwaysSample()),
|
||||||
|
sdktrace.WithBatcher(traceExporter),
|
||||||
|
sdktrace.WithResource(res),
|
||||||
|
), sdkmetric.NewMeterProvider(
|
||||||
|
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExporter,
|
||||||
|
sdkmetric.WithInterval(time.Second*30),
|
||||||
|
)),
|
||||||
|
sdkmetric.WithResource(res),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUser(ctx context.Context, id string) string {
|
||||||
|
_, span := tracer.Start(ctx, "getUser", oteltrace.WithAttributes(attribute.String("id", id)))
|
||||||
|
defer span.End()
|
||||||
|
if id == "123" {
|
||||||
|
return "otelchi tester"
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
6
examples/multi-services/Makefile
Normal file
6
examples/multi-services/Makefile
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
run:
|
||||||
|
-make down
|
||||||
|
docker-compose up --build
|
||||||
|
|
||||||
|
down:
|
||||||
|
docker-compose down --remove-orphans
|
33
examples/multi-services/README.md
Normal file
33
examples/multi-services/README.md
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
# Multi Services Example
|
||||||
|
|
||||||
|
This is a simple example of how instrumenting multiple services.
|
||||||
|
|
||||||
|
There are 2 services in this example:
|
||||||
|
|
||||||
|
- `front-svc` => the front service receiving requests from the client
|
||||||
|
- `back-svc` => the service that is being called by `front-svc` (hence named `back`)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
All traces will be collected in [Jaeger](https://www.jaegertracing.io/).
|
||||||
|
|
||||||
|
## How to Run
|
||||||
|
|
||||||
|
Make sure to have Docker & Docker Compose installed in your system. After that run this command:
|
||||||
|
|
||||||
|
```
|
||||||
|
> make run
|
||||||
|
```
|
||||||
|
|
||||||
|
If the command runs successfully (it will take a moment), you will see something like this in the terminal:
|
||||||
|
|
||||||
|
```
|
||||||
|
back-svc_1 | 2022/07/23 01:49:29 back service is listening on :8091
|
||||||
|
front-svc_1 | 2022/07/23 01:49:26 front service is listening on :8090
|
||||||
|
...
|
||||||
|
multi-services_client_1 exited with code 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Open your browser and access `http://localhost:16686` to access the Jaeger UI.
|
||||||
|
|
||||||
|
You should see some traces available already in the UI.
|
4
examples/multi-services/architecture.svg
Normal file
4
examples/multi-services/architecture.svg
Normal file
File diff suppressed because one or more lines are too long
After (image error) Size: 7.6 KiB |
13
examples/multi-services/back-svc/Dockerfile
Normal file
13
examples/multi-services/back-svc/Dockerfile
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
FROM golang:1.22-alpine3.20
|
||||||
|
WORKDIR /go/src/toastielab.dev/toastie-stuff/otelchi
|
||||||
|
|
||||||
|
RUN apk --no-cache add curl
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
WORKDIR /go/src/toastielab.dev/toastie-stuff/otelchi/examples/multi-services
|
||||||
|
RUN go mod download -x
|
||||||
|
|
||||||
|
WORKDIR /go/src/toastielab.dev/toastie-stuff/otelchi/examples/multi-services/back-svc
|
||||||
|
RUN go build -o server
|
||||||
|
|
||||||
|
ENTRYPOINT ["./server"]
|
54
examples/multi-services/back-svc/main.go
Normal file
54
examples/multi-services/back-svc/main.go
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
|
||||||
|
"toastielab.dev/toastie-stuff/otelchi"
|
||||||
|
"toastielab.dev/toastie-stuff/otelchi/examples/multi-services/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
addr = ":8091"
|
||||||
|
serviceName = "back-svc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// init tracer provider
|
||||||
|
tracer, err := utils.NewTracer(serviceName)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("unable to initialize tracer provider due: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = utils.NewMeter(serviceName); err != nil {
|
||||||
|
log.Fatalf("unable to initialize meter provider due: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// define router
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(otelchi.Middleware(serviceName, otelchi.WithChiRoutes(r)))
|
||||||
|
r.Get("/", utils.HealthCheckHandler)
|
||||||
|
r.Get("/name", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte(generateName(r.Context(), tracer)))
|
||||||
|
})
|
||||||
|
log.Printf("back service is listening on %v", addr)
|
||||||
|
err = http.ListenAndServe(addr, r)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("unable to execute server due: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateName(ctx context.Context, tracer trace.Tracer) string {
|
||||||
|
_, span := tracer.Start(ctx, "generateName")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
rndNum := rand.New(rand.NewSource(time.Now().UnixNano())).Intn(100000)
|
||||||
|
return fmt.Sprintf("user_%v", rndNum)
|
||||||
|
}
|
13
examples/multi-services/front-svc/Dockerfile
Normal file
13
examples/multi-services/front-svc/Dockerfile
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
FROM golang:1.22-alpine3.20
|
||||||
|
WORKDIR /go/src/toastielab.dev/toastie-stuff/otelchi
|
||||||
|
|
||||||
|
RUN apk --no-cache add curl
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
WORKDIR /go/src/toastielab.dev/toastie-stuff/otelchi/examples/multi-services
|
||||||
|
RUN go mod download -x
|
||||||
|
|
||||||
|
WORKDIR /go/src/toastielab.dev/toastie-stuff/otelchi/examples/multi-services/front-svc
|
||||||
|
RUN go build -o server
|
||||||
|
|
||||||
|
ENTRYPOINT ["./server"]
|
84
examples/multi-services/front-svc/main.go
Normal file
84
examples/multi-services/front-svc/main.go
Normal file
|
@ -0,0 +1,84 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
|
"go.opentelemetry.io/otel/codes"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
|
||||||
|
"toastielab.dev/toastie-stuff/otelchi"
|
||||||
|
"toastielab.dev/toastie-stuff/otelchi/examples/multi-services/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
envKeyBackServiceURL = "BACK_SERVICE_URL"
|
||||||
|
addr = ":8090"
|
||||||
|
serviceName = "front-svc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// initialize tracer
|
||||||
|
tracer, err := utils.NewTracer(serviceName)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("unable to initialize tracer due: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = utils.NewMeter(serviceName); err != nil {
|
||||||
|
log.Fatalf("unable to initialize meter provider due: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// define router
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(otelchi.Middleware(serviceName, otelchi.WithChiRoutes(r)))
|
||||||
|
r.Get("/", utils.HealthCheckHandler)
|
||||||
|
r.Get("/greet", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name, err := getRandomName(r.Context(), tracer)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write([]byte(fmt.Sprintf("Hello, %s!", name)))
|
||||||
|
})
|
||||||
|
// execute server
|
||||||
|
log.Printf("front service is listening on %v", addr)
|
||||||
|
err = http.ListenAndServe(addr, r)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("unable to execute server due: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRandomName(ctx context.Context, tracer trace.Tracer) (string, error) {
|
||||||
|
// start span
|
||||||
|
ctx, span := tracer.Start(ctx, "getRandomName")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
// call back service, notice that here we call the service using instrumented
|
||||||
|
// http client
|
||||||
|
resp, err := otelhttp.Get(ctx, os.Getenv(envKeyBackServiceURL)+"/name")
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("unable to execute http request due: %w", err)
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// read response body
|
||||||
|
data, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("unable to read response data due: %w", err)
|
||||||
|
span.RecordError(err)
|
||||||
|
span.SetStatus(codes.Error, err.Error())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(data), nil
|
||||||
|
}
|
36
examples/multi-services/go.mod
Normal file
36
examples/multi-services/go.mod
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
module toastielab.dev/toastie-stuff/otelchi/examples/multi-services
|
||||||
|
|
||||||
|
go 1.22.0
|
||||||
|
|
||||||
|
replace toastielab.dev/toastie-stuff/otelchi => ../../
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0
|
||||||
|
toastielab.dev/toastie-stuff/otelchi v0.10.0
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0
|
||||||
|
go.opentelemetry.io/otel v1.30.0
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.30.0
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0
|
||||||
|
go.opentelemetry.io/otel/sdk v1.30.0
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.30.0
|
||||||
|
go.opentelemetry.io/otel/trace v1.30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.2 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.30.0 // indirect
|
||||||
|
go.opentelemetry.io/proto/otlp v1.3.1 // indirect
|
||||||
|
golang.org/x/net v0.29.0 // indirect
|
||||||
|
golang.org/x/sys v0.25.0 // indirect
|
||||||
|
golang.org/x/text v0.18.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||||
|
google.golang.org/grpc v1.66.1 // indirect
|
||||||
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
|
)
|
25
examples/multi-services/go.sum
Normal file
25
examples/multi-services/go.sum
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0/go.mod h1:jjdQuTGVsXV4vSs+CJ2qYDeDPf9yIJV23qlIzBm73Vg=
|
||||||
|
go.opentelemetry.io/otel v1.30.0/go.mod h1:tFw4Br9b7fOS+uEao81PJjVMjW/5fvNCbpsDIXqP0pc=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.30.0/go.mod h1:qqN/uFdpeitTvm+JDqqnjm517pmQRYxTORbETHq5tOc=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.30.0/go.mod h1:KQsVNh4OjgjTG0G6EiNi1jVpnaeeKsKMRwbLN+f1+8M=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0/go.mod h1:4lVs6obhSVRb1EW5FhOuBTyiQhtRtAnnva9vD3yRfq8=
|
||||||
|
go.opentelemetry.io/otel/metric v1.30.0/go.mod h1:aXTfST94tswhWEb+5QjlSqG+cZlmyXy/u8jFpor3WqQ=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.30.0/go.mod h1:p14X4Ok8S+sygzblytT1nqG98QG2KYKv++HE0LY/mhg=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.30.0/go.mod h1:waS6P3YqFNzeP01kuo/MBBYqaoBJl7efRQHOaydhy1Y=
|
||||||
|
go.opentelemetry.io/otel/trace v1.30.0/go.mod h1:5EyKqTzzmyqB9bwtCCq6pDLktPK6fmGf/Dph+8VI02o=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
|
||||||
|
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||||
|
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:qpvKtACPCQhAdu3PyQgV4l3LMXZEtft7y8QcarRsp9I=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||||
|
google.golang.org/grpc v1.66.1/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
|
||||||
|
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
7
examples/multi-services/utils/healthcheck.go
Normal file
7
examples/multi-services/utils/healthcheck.go
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func HealthCheckHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Write([]byte("It's working!"))
|
||||||
|
}
|
39
examples/multi-services/utils/meter.go
Normal file
39
examples/multi-services/utils/meter.go
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
|
||||||
|
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||||
|
"go.opentelemetry.io/otel/sdk/resource"
|
||||||
|
semconv "go.opentelemetry.io/otel/semconv/v1.20.0"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewMeter(svcName string) error {
|
||||||
|
// create otlp exporter, notice that here we are using insecure option
|
||||||
|
// because we just want to export the trace locally, also notice that
|
||||||
|
// here we don't set any endpoint because by default the otel will load
|
||||||
|
// the endpoint from the environment variable `OTEL_EXPORTER_OTLP_ENDPOINT`
|
||||||
|
exporter, err := otlpmetrichttp.New(
|
||||||
|
context.Background(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to initialize exporter due: %w", err)
|
||||||
|
}
|
||||||
|
// initialize tracer provider
|
||||||
|
tp := sdkmetric.NewMeterProvider(
|
||||||
|
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exporter, sdkmetric.WithInterval(30*time.Second))),
|
||||||
|
sdkmetric.WithResource(resource.NewWithAttributes(
|
||||||
|
semconv.SchemaURL,
|
||||||
|
semconv.ServiceNameKey.String(svcName),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
// set tracer provider and propagator properly, this is to ensure all
|
||||||
|
// instrumentation library could run well
|
||||||
|
otel.SetMeterProvider(tp)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
45
examples/multi-services/utils/tracer.go
Normal file
45
examples/multi-services/utils/tracer.go
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
|
||||||
|
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||||
|
"go.opentelemetry.io/otel/propagation"
|
||||||
|
"go.opentelemetry.io/otel/sdk/resource"
|
||||||
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||||
|
semconv "go.opentelemetry.io/otel/semconv/v1.20.0"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewTracer(svcName string) (trace.Tracer, error) {
|
||||||
|
// create otlp exporter, notice that here we are using insecure option
|
||||||
|
// because we just want to export the trace locally, also notice that
|
||||||
|
// here we don't set any endpoint because by default the otel will load
|
||||||
|
// the endpoint from the environment variable `OTEL_EXPORTER_OTLP_ENDPOINT`
|
||||||
|
exporter, err := otlptrace.New(
|
||||||
|
context.Background(),
|
||||||
|
otlptracehttp.NewClient(otlptracehttp.WithInsecure()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to initialize exporter due: %w", err)
|
||||||
|
}
|
||||||
|
// initialize tracer provider
|
||||||
|
tp := sdktrace.NewTracerProvider(
|
||||||
|
sdktrace.WithSampler(sdktrace.AlwaysSample()),
|
||||||
|
sdktrace.WithBatcher(exporter),
|
||||||
|
sdktrace.WithResource(resource.NewWithAttributes(
|
||||||
|
semconv.SchemaURL,
|
||||||
|
semconv.ServiceNameKey.String(svcName),
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
// set tracer provider and propagator properly, this is to ensure all
|
||||||
|
// instrumentation library could run well
|
||||||
|
otel.SetTracerProvider(tp)
|
||||||
|
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{}))
|
||||||
|
|
||||||
|
// returns tracer
|
||||||
|
return otel.Tracer(svcName), nil
|
||||||
|
}
|
23
go.mod
Normal file
23
go.mod
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
module toastielab.dev/toastie-stuff/otelchi
|
||||||
|
|
||||||
|
go 1.22.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/felixge/httpsnoop v1.0.4
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0
|
||||||
|
github.com/stretchr/testify v1.9.0
|
||||||
|
go.opentelemetry.io/otel v1.30.0
|
||||||
|
go.opentelemetry.io/otel/metric v1.30.0
|
||||||
|
go.opentelemetry.io/otel/sdk v1.30.0
|
||||||
|
go.opentelemetry.io/otel/trace v1.30.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.2 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
golang.org/x/sys v0.25.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
16
go.sum
Normal file
16
go.sum
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
|
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||||
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
go.opentelemetry.io/otel v1.30.0/go.mod h1:tFw4Br9b7fOS+uEao81PJjVMjW/5fvNCbpsDIXqP0pc=
|
||||||
|
go.opentelemetry.io/otel/metric v1.30.0/go.mod h1:aXTfST94tswhWEb+5QjlSqG+cZlmyXy/u8jFpor3WqQ=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.30.0/go.mod h1:p14X4Ok8S+sygzblytT1nqG98QG2KYKv++HE0LY/mhg=
|
||||||
|
go.opentelemetry.io/otel/trace v1.30.0/go.mod h1:5EyKqTzzmyqB9bwtCCq6pDLktPK6fmGf/Dph+8VI02o=
|
||||||
|
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
88
metrics.go
Normal file
88
metrics.go
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
package otelchi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
otelmetric "go.opentelemetry.io/otel/metric"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
serviceKey = attribute.Key("service")
|
||||||
|
idKey = attribute.Key("id")
|
||||||
|
methodKey = attribute.Key("method")
|
||||||
|
codeKey = attribute.Key("code")
|
||||||
|
)
|
||||||
|
|
||||||
|
type httpReqProperties struct {
|
||||||
|
Service string
|
||||||
|
ID string
|
||||||
|
Method string
|
||||||
|
Code int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMetricsRecorder(meter otelmetric.Meter) *metricsRecorder {
|
||||||
|
httpRequestDurHistogram, err := meter.Int64Histogram("request_duration_seconds")
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to create request_duration_seconds histogram: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
httpResponseSizeHistogram, err := meter.Int64Histogram("response_size_bytes")
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to create response_size_bytes histogram: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
httpRequestsInflight, err := meter.Int64UpDownCounter("requests_inflight")
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Sprintf("failed to create requests_inflight counter: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &metricsRecorder{
|
||||||
|
httpRequestDurHistogram: httpRequestDurHistogram,
|
||||||
|
httpResponseSizeHistogram: httpResponseSizeHistogram,
|
||||||
|
httpRequestsInflight: httpRequestsInflight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type metricsRecorder struct {
|
||||||
|
httpRequestDurHistogram otelmetric.Int64Histogram
|
||||||
|
httpResponseSizeHistogram otelmetric.Int64Histogram
|
||||||
|
httpRequestsInflight otelmetric.Int64UpDownCounter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *metricsRecorder) RecordRequestDuration(ctx context.Context, p httpReqProperties, duration time.Duration) {
|
||||||
|
r.httpRequestDurHistogram.Record(ctx,
|
||||||
|
int64(duration.Seconds()),
|
||||||
|
otelmetric.WithAttributes(
|
||||||
|
serviceKey.String(p.Service),
|
||||||
|
idKey.String(p.ID),
|
||||||
|
methodKey.String(p.Method),
|
||||||
|
codeKey.Int(p.Code),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *metricsRecorder) RecordResponseSize(ctx context.Context, p httpReqProperties, size int64) {
|
||||||
|
r.httpResponseSizeHistogram.Record(ctx,
|
||||||
|
size,
|
||||||
|
otelmetric.WithAttributes(
|
||||||
|
serviceKey.String(p.Service),
|
||||||
|
idKey.String(p.ID),
|
||||||
|
methodKey.String(p.Method),
|
||||||
|
codeKey.Int(p.Code),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *metricsRecorder) RecordRequestsInflight(ctx context.Context, p httpReqProperties, count int64) {
|
||||||
|
r.httpRequestsInflight.Add(ctx,
|
||||||
|
count,
|
||||||
|
otelmetric.WithAttributes(
|
||||||
|
serviceKey.String(p.Service),
|
||||||
|
idKey.String(p.ID),
|
||||||
|
methodKey.String(p.Method),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
270
middleware.go
Normal file
270
middleware.go
Normal file
|
@ -0,0 +1,270 @@
|
||||||
|
package otelchi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/felixge/httpsnoop"
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
"go.opentelemetry.io/otel/propagation"
|
||||||
|
|
||||||
|
otelmetric "go.opentelemetry.io/otel/metric"
|
||||||
|
semconv "go.opentelemetry.io/otel/semconv/v1.20.0"
|
||||||
|
"go.opentelemetry.io/otel/semconv/v1.20.0/httpconv"
|
||||||
|
oteltrace "go.opentelemetry.io/otel/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tracerName = "toastielab.dev/toastie-stuff/otelchi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Middleware sets up a handler to start tracing the incoming
|
||||||
|
// requests. The serverName parameter should describe the name of the
|
||||||
|
// (virtual) server handling the request.
|
||||||
|
func Middleware(serverName string, opts ...Option) func(http.Handler) http.Handler {
|
||||||
|
cfg := config{}
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt.apply(&cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.TracerProvider == nil {
|
||||||
|
cfg.TracerProvider = otel.GetTracerProvider()
|
||||||
|
}
|
||||||
|
tracer := cfg.TracerProvider.Tracer(
|
||||||
|
tracerName,
|
||||||
|
oteltrace.WithInstrumentationVersion(Version()),
|
||||||
|
)
|
||||||
|
|
||||||
|
if cfg.MeterProvider == nil {
|
||||||
|
cfg.MeterProvider = otel.GetMeterProvider()
|
||||||
|
}
|
||||||
|
meter := cfg.MeterProvider.Meter(
|
||||||
|
tracerName,
|
||||||
|
otelmetric.WithInstrumentationVersion(Version()),
|
||||||
|
)
|
||||||
|
|
||||||
|
if cfg.Propagators == nil {
|
||||||
|
cfg.Propagators = otel.GetTextMapPropagator()
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(handler http.Handler) http.Handler {
|
||||||
|
return &otelware{
|
||||||
|
serverName: serverName,
|
||||||
|
tracer: tracer,
|
||||||
|
meter: meter,
|
||||||
|
recorder: newMetricsRecorder(meter),
|
||||||
|
propagators: cfg.Propagators,
|
||||||
|
handler: handler,
|
||||||
|
chiRoutes: cfg.ChiRoutes,
|
||||||
|
reqMethodInSpanName: cfg.RequestMethodInSpanName,
|
||||||
|
filters: cfg.Filters,
|
||||||
|
traceIDResponseHeaderKey: cfg.TraceIDResponseHeaderKey,
|
||||||
|
traceSampledResponseHeaderKey: cfg.TraceSampledResponseHeaderKey,
|
||||||
|
publicEndpointFn: cfg.PublicEndpointFn,
|
||||||
|
disableMeasureInflight: cfg.DisableMeasureInflight,
|
||||||
|
disableMeasureSize: cfg.DisableMeasureSize,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type otelware struct {
|
||||||
|
serverName string
|
||||||
|
tracer oteltrace.Tracer
|
||||||
|
meter otelmetric.Meter
|
||||||
|
recorder *metricsRecorder
|
||||||
|
propagators propagation.TextMapPropagator
|
||||||
|
handler http.Handler
|
||||||
|
chiRoutes chi.Routes
|
||||||
|
reqMethodInSpanName bool
|
||||||
|
filters []Filter
|
||||||
|
traceIDResponseHeaderKey string
|
||||||
|
traceSampledResponseHeaderKey string
|
||||||
|
publicEndpointFn func(r *http.Request) bool
|
||||||
|
|
||||||
|
disableMeasureInflight bool
|
||||||
|
disableMeasureSize bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type recordingResponseWriter struct {
|
||||||
|
writer http.ResponseWriter
|
||||||
|
written bool
|
||||||
|
writtenBytes int64
|
||||||
|
status int
|
||||||
|
}
|
||||||
|
|
||||||
|
var rrwPool = &sync.Pool{
|
||||||
|
New: func() interface{} {
|
||||||
|
return &recordingResponseWriter{}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRRW(writer http.ResponseWriter) *recordingResponseWriter {
|
||||||
|
rrw := rrwPool.Get().(*recordingResponseWriter)
|
||||||
|
rrw.written = false
|
||||||
|
rrw.writtenBytes = 0
|
||||||
|
rrw.status = http.StatusOK
|
||||||
|
rrw.writer = httpsnoop.Wrap(writer, httpsnoop.Hooks{
|
||||||
|
Write: func(next httpsnoop.WriteFunc) httpsnoop.WriteFunc {
|
||||||
|
return func(b []byte) (int, error) {
|
||||||
|
if !rrw.written {
|
||||||
|
rrw.written = true
|
||||||
|
rrw.writtenBytes += int64(len(b))
|
||||||
|
}
|
||||||
|
return next(b)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
WriteHeader: func(next httpsnoop.WriteHeaderFunc) httpsnoop.WriteHeaderFunc {
|
||||||
|
return func(statusCode int) {
|
||||||
|
if !rrw.written {
|
||||||
|
rrw.written = true
|
||||||
|
rrw.status = statusCode
|
||||||
|
}
|
||||||
|
next(statusCode)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return rrw
|
||||||
|
}
|
||||||
|
|
||||||
|
func putRRW(rrw *recordingResponseWriter) {
|
||||||
|
rrw.writer = nil
|
||||||
|
rrwPool.Put(rrw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP implements the http.Handler interface. It does the actual
|
||||||
|
// tracing of the request.
|
||||||
|
func (ow *otelware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// go through all filters if any
|
||||||
|
for _, filter := range ow.filters {
|
||||||
|
// if there is a filter that returns false, we skip tracing
|
||||||
|
// and execute next handler
|
||||||
|
if !filter(r) {
|
||||||
|
ow.handler.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extract tracing header using propagator
|
||||||
|
ctx := ow.propagators.Extract(r.Context(), propagation.HeaderCarrier(r.Header))
|
||||||
|
// create span, based on specification, we need to set already known attributes
|
||||||
|
// when creating the span, the only thing missing here is HTTP route pattern since
|
||||||
|
// in go-chi/chi route pattern could only be extracted once the request is executed
|
||||||
|
// check here for details:
|
||||||
|
//
|
||||||
|
// https://github.com/go-chi/chi/issues/150#issuecomment-278850733
|
||||||
|
//
|
||||||
|
// if we have access to chi routes, we could extract the route pattern beforehand.
|
||||||
|
spanName := ""
|
||||||
|
routePattern := ""
|
||||||
|
spanAttributes := httpconv.ServerRequest(ow.serverName, r)
|
||||||
|
|
||||||
|
if ow.chiRoutes != nil {
|
||||||
|
rctx := chi.NewRouteContext()
|
||||||
|
if ow.chiRoutes.Match(rctx, r.Method, r.URL.Path) {
|
||||||
|
routePattern = rctx.RoutePattern()
|
||||||
|
spanName = addPrefixToSpanName(ow.reqMethodInSpanName, r.Method, routePattern)
|
||||||
|
spanAttributes = append(spanAttributes, semconv.HTTPRoute(routePattern))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// define span start options
|
||||||
|
spanOpts := []oteltrace.SpanStartOption{
|
||||||
|
oteltrace.WithAttributes(spanAttributes...),
|
||||||
|
oteltrace.WithSpanKind(oteltrace.SpanKindServer),
|
||||||
|
}
|
||||||
|
|
||||||
|
if ow.publicEndpointFn != nil && ow.publicEndpointFn(r) {
|
||||||
|
// mark span as the root span
|
||||||
|
spanOpts = append(spanOpts, oteltrace.WithNewRoot())
|
||||||
|
|
||||||
|
// linking incoming span context to the root span, we need to
|
||||||
|
// ensure if the incoming span context is valid (because it is
|
||||||
|
// possible for us to receive invalid span context due to various
|
||||||
|
// reason such as bug or context propagation error) and it is
|
||||||
|
// coming from another service (remote) before linking it to the
|
||||||
|
// root span
|
||||||
|
spanCtx := oteltrace.SpanContextFromContext(ctx)
|
||||||
|
if spanCtx.IsValid() && spanCtx.IsRemote() {
|
||||||
|
spanOpts = append(
|
||||||
|
spanOpts,
|
||||||
|
oteltrace.WithLinks(oteltrace.Link{
|
||||||
|
SpanContext: spanCtx,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
props := httpReqProperties{
|
||||||
|
Service: ow.serverName,
|
||||||
|
ID: routePattern,
|
||||||
|
Method: r.Method,
|
||||||
|
}
|
||||||
|
if routePattern == "" {
|
||||||
|
props.ID = r.URL.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ow.disableMeasureInflight {
|
||||||
|
ow.recorder.RecordRequestsInflight(ctx, props, 1)
|
||||||
|
defer ow.recorder.RecordRequestsInflight(ctx, props, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// start span
|
||||||
|
ctx, span := ow.tracer.Start(ctx, spanName, spanOpts...)
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
// put trace_id to response header only when `WithTraceIDResponseHeader` is used
|
||||||
|
if len(ow.traceIDResponseHeaderKey) > 0 && span.SpanContext().HasTraceID() {
|
||||||
|
w.Header().Add(ow.traceIDResponseHeaderKey, span.SpanContext().TraceID().String())
|
||||||
|
w.Header().Add(ow.traceSampledResponseHeaderKey, strconv.FormatBool(span.SpanContext().IsSampled()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// get recording response writer
|
||||||
|
rrw := getRRW(w)
|
||||||
|
defer putRRW(rrw)
|
||||||
|
|
||||||
|
// execute next http handler
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
start := time.Now()
|
||||||
|
ow.handler.ServeHTTP(rrw.writer, r)
|
||||||
|
duration := time.Since(start)
|
||||||
|
|
||||||
|
props.Code = rrw.status
|
||||||
|
ow.recorder.RecordRequestDuration(ctx, props, duration)
|
||||||
|
|
||||||
|
if !ow.disableMeasureSize {
|
||||||
|
ow.recorder.RecordResponseSize(ctx, props, rrw.writtenBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// set span name & http route attribute if route pattern cannot be determined
|
||||||
|
// during span creation
|
||||||
|
if len(routePattern) == 0 {
|
||||||
|
routePattern = chi.RouteContext(r.Context()).RoutePattern()
|
||||||
|
span.SetAttributes(semconv.HTTPRoute(routePattern))
|
||||||
|
|
||||||
|
spanName = addPrefixToSpanName(ow.reqMethodInSpanName, r.Method, routePattern)
|
||||||
|
span.SetName(spanName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// set status code attribute
|
||||||
|
span.SetAttributes(semconv.HTTPStatusCode(rrw.status))
|
||||||
|
|
||||||
|
// set span status
|
||||||
|
span.SetStatus(httpconv.ServerStatus(rrw.status))
|
||||||
|
}
|
||||||
|
|
||||||
|
func addPrefixToSpanName(shouldAdd bool, prefix, spanName string) string {
|
||||||
|
// in chi v5.0.8, the root route will be returned has an empty string
|
||||||
|
// (see https://github.com/go-chi/chi/blob/v5.0.8/context.go#L126)
|
||||||
|
if spanName == "" {
|
||||||
|
spanName = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldAdd && len(spanName) > 0 {
|
||||||
|
spanName = prefix + " " + spanName
|
||||||
|
}
|
||||||
|
return spanName
|
||||||
|
}
|
150
test/cases/middleware_test.go
Normal file
150
test/cases/middleware_test.go
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
package otelchi_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
"go.opentelemetry.io/otel/propagation"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
"toastielab.dev/toastie-stuff/otelchi"
|
||||||
|
)
|
||||||
|
|
||||||
|
var sc = trace.NewSpanContext(trace.SpanContextConfig{
|
||||||
|
TraceID: [16]byte{1},
|
||||||
|
SpanID: [8]byte{1},
|
||||||
|
Remote: true,
|
||||||
|
TraceFlags: trace.FlagsSampled,
|
||||||
|
})
|
||||||
|
|
||||||
|
func TestPassthroughSpanFromGlobalTracer(t *testing.T) {
|
||||||
|
var called bool
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(otelchi.Middleware("foobar"))
|
||||||
|
// The default global TracerProvider provides "pass through" spans for any
|
||||||
|
// span context in the incoming request context.
|
||||||
|
router.HandleFunc("/user/{id}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
got := trace.SpanFromContext(r.Context()).SpanContext()
|
||||||
|
assert.Equal(t, sc, got)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
r := httptest.NewRequest("GET", "/user/123", nil)
|
||||||
|
r = r.WithContext(trace.ContextWithRemoteSpanContext(context.Background(), sc))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, r)
|
||||||
|
assert.True(t, called, "failed to run test")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPropagationWithGlobalPropagators(t *testing.T) {
|
||||||
|
defer func(p propagation.TextMapPropagator) {
|
||||||
|
otel.SetTextMapPropagator(p)
|
||||||
|
}(otel.GetTextMapPropagator())
|
||||||
|
|
||||||
|
prop := propagation.TraceContext{}
|
||||||
|
otel.SetTextMapPropagator(prop)
|
||||||
|
|
||||||
|
r := httptest.NewRequest("GET", "/user/123", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ctx := trace.ContextWithRemoteSpanContext(context.Background(), sc)
|
||||||
|
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(r.Header))
|
||||||
|
|
||||||
|
var called bool
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(otelchi.Middleware("foobar"))
|
||||||
|
router.HandleFunc("/user/{id}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
span := trace.SpanFromContext(r.Context())
|
||||||
|
assert.Equal(t, sc, span.SpanContext())
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
router.ServeHTTP(w, r)
|
||||||
|
assert.True(t, called, "failed to run test")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPropagationWithCustomPropagators(t *testing.T) {
|
||||||
|
prop := propagation.TraceContext{}
|
||||||
|
|
||||||
|
r := httptest.NewRequest("GET", "/user/123", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ctx := trace.ContextWithRemoteSpanContext(context.Background(), sc)
|
||||||
|
prop.Inject(ctx, propagation.HeaderCarrier(r.Header))
|
||||||
|
|
||||||
|
var called bool
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(otelchi.Middleware("foobar", otelchi.WithPropagators(prop)))
|
||||||
|
router.HandleFunc("/user/{id}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
called = true
|
||||||
|
span := trace.SpanFromContext(r.Context())
|
||||||
|
assert.Equal(t, sc, span.SpanContext())
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
router.ServeHTTP(w, r)
|
||||||
|
assert.True(t, called, "failed to run test")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponseWriterInterfaces(t *testing.T) {
|
||||||
|
// make sure the recordingResponseWriter preserves interfaces implemented by the wrapped writer
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(otelchi.Middleware("foobar"))
|
||||||
|
router.HandleFunc("/user/{id}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
assert.Implements(t, (*http.Hijacker)(nil), w)
|
||||||
|
assert.Implements(t, (*http.Pusher)(nil), w)
|
||||||
|
assert.Implements(t, (*http.Flusher)(nil), w)
|
||||||
|
assert.Implements(t, (*io.ReaderFrom)(nil), w)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
|
||||||
|
r := httptest.NewRequest("GET", "/user/123", nil)
|
||||||
|
w := &testResponseWriter{
|
||||||
|
writer: httptest.NewRecorder(),
|
||||||
|
}
|
||||||
|
|
||||||
|
router.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
type testResponseWriter struct {
|
||||||
|
writer http.ResponseWriter
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rw *testResponseWriter) Header() http.Header {
|
||||||
|
return rw.writer.Header()
|
||||||
|
}
|
||||||
|
func (rw *testResponseWriter) Write(b []byte) (int, error) {
|
||||||
|
return rw.writer.Write(b)
|
||||||
|
}
|
||||||
|
func (rw *testResponseWriter) WriteHeader(statusCode int) {
|
||||||
|
rw.writer.WriteHeader(statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// implement Hijacker
|
||||||
|
func (rw *testResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// implement Pusher
|
||||||
|
func (rw *testResponseWriter) Push(target string, opts *http.PushOptions) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// implement Flusher
|
||||||
|
func (rw *testResponseWriter) Flush() {
|
||||||
|
}
|
||||||
|
|
||||||
|
// implement io.ReaderFrom
|
||||||
|
func (rw *testResponseWriter) ReadFrom(r io.Reader) (n int64, err error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
782
test/cases/sdk_test.go
Normal file
782
test/cases/sdk_test.go
Normal file
|
@ -0,0 +1,782 @@
|
||||||
|
package otelchi_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
"go.opentelemetry.io/otel/propagation"
|
||||||
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||||
|
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
"toastielab.dev/toastie-stuff/otelchi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSDKIntegration(t *testing.T) {
|
||||||
|
// prepare router and span recorder
|
||||||
|
router, sr := newSDKTestRouter("foobar", false)
|
||||||
|
|
||||||
|
// define routes
|
||||||
|
router.HandleFunc("/user/{id:[0-9]+}", ok)
|
||||||
|
router.HandleFunc("/book/{title}", ok)
|
||||||
|
|
||||||
|
// execute requests
|
||||||
|
reqs := []*http.Request{
|
||||||
|
httptest.NewRequest("GET", "/user/123", nil),
|
||||||
|
httptest.NewRequest("GET", "/book/foo", nil),
|
||||||
|
}
|
||||||
|
executeRequests(router, reqs)
|
||||||
|
|
||||||
|
// get recorded spans
|
||||||
|
recordedSpans := sr.Ended()
|
||||||
|
|
||||||
|
// ensure that we have 2 recorded spans
|
||||||
|
require.Len(t, recordedSpans, len(reqs))
|
||||||
|
|
||||||
|
// ensure span values
|
||||||
|
checkSpans(t, recordedSpans, []spanValueCheck{
|
||||||
|
{
|
||||||
|
Name: "/user/{id:[0-9]+}",
|
||||||
|
Kind: trace.SpanKindServer,
|
||||||
|
Attributes: getSemanticAttributes(
|
||||||
|
"foobar",
|
||||||
|
http.StatusOK,
|
||||||
|
"GET",
|
||||||
|
"/user/{id:[0-9]+}",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "/book/{title}",
|
||||||
|
Kind: trace.SpanKindServer,
|
||||||
|
Attributes: getSemanticAttributes(
|
||||||
|
"foobar",
|
||||||
|
http.StatusOK,
|
||||||
|
"GET",
|
||||||
|
"/book/{title}",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDKIntegrationWithFilter(t *testing.T) {
|
||||||
|
// prepare test cases
|
||||||
|
serviceName := "foobar"
|
||||||
|
testCases := []struct {
|
||||||
|
Name string
|
||||||
|
FilterFn []otelchi.Filter
|
||||||
|
LenSpans int
|
||||||
|
ExpectedRouteNames []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "One WithFilter",
|
||||||
|
FilterFn: []otelchi.Filter{
|
||||||
|
func(r *http.Request) bool {
|
||||||
|
return r.URL.Path != "/live" && r.URL.Path != "/ready"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
LenSpans: 2,
|
||||||
|
ExpectedRouteNames: []string{"/user/{id:[0-9]+}", "/book/{title}"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Multiple WithFilter",
|
||||||
|
FilterFn: []otelchi.Filter{
|
||||||
|
func(r *http.Request) bool {
|
||||||
|
return r.URL.Path != "/ready"
|
||||||
|
},
|
||||||
|
func(r *http.Request) bool {
|
||||||
|
return r.URL.Path != "/live"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
LenSpans: 2,
|
||||||
|
ExpectedRouteNames: []string{"/user/{id:[0-9]+}", "/book/{title}"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "All Routes are traced",
|
||||||
|
FilterFn: []otelchi.Filter{
|
||||||
|
func(r *http.Request) bool {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
},
|
||||||
|
LenSpans: 4,
|
||||||
|
ExpectedRouteNames: []string{"/user/{id:[0-9]+}", "/book/{title}", "/live", "/ready"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "All Routes are not traced",
|
||||||
|
FilterFn: []otelchi.Filter{
|
||||||
|
func(r *http.Request) bool {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
LenSpans: 0,
|
||||||
|
ExpectedRouteNames: []string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// execute test cases
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.Name, func(t *testing.T) {
|
||||||
|
// prepare router and span recorder
|
||||||
|
filters := []otelchi.Option{}
|
||||||
|
for _, filter := range testCase.FilterFn {
|
||||||
|
filters = append(filters, otelchi.WithFilter(filter))
|
||||||
|
}
|
||||||
|
router, sr := newSDKTestRouter(serviceName, false, filters...)
|
||||||
|
|
||||||
|
// define router
|
||||||
|
router.HandleFunc("/user/{id:[0-9]+}", ok)
|
||||||
|
router.HandleFunc("/book/{title}", ok)
|
||||||
|
router.HandleFunc("/health", ok)
|
||||||
|
router.HandleFunc("/live", ok)
|
||||||
|
router.HandleFunc("/ready", ok)
|
||||||
|
|
||||||
|
// execute requests
|
||||||
|
executeRequests(router, []*http.Request{
|
||||||
|
httptest.NewRequest("GET", "/user/123", nil),
|
||||||
|
httptest.NewRequest("GET", "/book/foo", nil),
|
||||||
|
httptest.NewRequest("GET", "/live", nil),
|
||||||
|
httptest.NewRequest("GET", "/ready", nil),
|
||||||
|
})
|
||||||
|
|
||||||
|
// check recorded spans
|
||||||
|
recordedSpans := sr.Ended()
|
||||||
|
require.Len(t, recordedSpans, testCase.LenSpans)
|
||||||
|
|
||||||
|
// ensure span values
|
||||||
|
spanValues := []spanValueCheck{}
|
||||||
|
for _, routeName := range testCase.ExpectedRouteNames {
|
||||||
|
spanValues = append(spanValues, spanValueCheck{
|
||||||
|
Name: routeName,
|
||||||
|
Kind: trace.SpanKindServer,
|
||||||
|
Attributes: getSemanticAttributes(
|
||||||
|
serviceName,
|
||||||
|
http.StatusOK,
|
||||||
|
"GET",
|
||||||
|
routeName,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
checkSpans(t, recordedSpans, spanValues)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDKIntegrationWithChiRoutes(t *testing.T) {
|
||||||
|
// define router & span recorder
|
||||||
|
router, sr := newSDKTestRouter("foobar", true)
|
||||||
|
|
||||||
|
// define route
|
||||||
|
router.HandleFunc("/user/{id:[0-9]+}", ok)
|
||||||
|
router.HandleFunc("/book/{title}", ok)
|
||||||
|
|
||||||
|
// execute requests
|
||||||
|
reqs := []*http.Request{
|
||||||
|
httptest.NewRequest("GET", "/user/123", nil),
|
||||||
|
httptest.NewRequest("GET", "/book/foo", nil),
|
||||||
|
}
|
||||||
|
executeRequests(router, reqs)
|
||||||
|
|
||||||
|
// get recorded spans
|
||||||
|
recordedSpans := sr.Ended()
|
||||||
|
|
||||||
|
// ensure that we have 2 recorded spans
|
||||||
|
require.Len(t, recordedSpans, len(reqs))
|
||||||
|
|
||||||
|
// ensure span values
|
||||||
|
checkSpans(t, recordedSpans, []spanValueCheck{
|
||||||
|
{
|
||||||
|
Name: "/user/{id:[0-9]+}",
|
||||||
|
Kind: trace.SpanKindServer,
|
||||||
|
Attributes: getSemanticAttributes(
|
||||||
|
"foobar",
|
||||||
|
http.StatusOK,
|
||||||
|
"GET",
|
||||||
|
"/user/{id:[0-9]+}",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "/book/{title}",
|
||||||
|
Kind: trace.SpanKindServer,
|
||||||
|
Attributes: getSemanticAttributes(
|
||||||
|
"foobar",
|
||||||
|
http.StatusOK,
|
||||||
|
"GET",
|
||||||
|
"/book/{title}",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDKIntegrationOverrideSpanName(t *testing.T) {
|
||||||
|
// prepare test router and span recorder
|
||||||
|
router, sr := newSDKTestRouter("foobar", true)
|
||||||
|
|
||||||
|
// define route
|
||||||
|
router.HandleFunc("/user/{id:[0-9]+}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
span := trace.SpanFromContext(r.Context())
|
||||||
|
span.SetName("overriden span name")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
router.HandleFunc("/book/{title}", ok)
|
||||||
|
|
||||||
|
// execute requests
|
||||||
|
reqs := []*http.Request{
|
||||||
|
httptest.NewRequest("GET", "/user/123", nil),
|
||||||
|
httptest.NewRequest("GET", "/book/foo", nil),
|
||||||
|
}
|
||||||
|
executeRequests(router, reqs)
|
||||||
|
|
||||||
|
// get recorded spans
|
||||||
|
recordedSpans := sr.Ended()
|
||||||
|
|
||||||
|
// ensure the number of spans is correct
|
||||||
|
require.Len(t, sr.Ended(), len(reqs))
|
||||||
|
|
||||||
|
// check span values
|
||||||
|
checkSpans(t, recordedSpans, []spanValueCheck{
|
||||||
|
{
|
||||||
|
Name: "overriden span name",
|
||||||
|
Kind: trace.SpanKindServer,
|
||||||
|
Attributes: getSemanticAttributes(
|
||||||
|
"foobar",
|
||||||
|
http.StatusOK,
|
||||||
|
"GET",
|
||||||
|
"/user/{id:[0-9]+}",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "/book/{title}",
|
||||||
|
Kind: trace.SpanKindServer,
|
||||||
|
Attributes: getSemanticAttributes(
|
||||||
|
"foobar",
|
||||||
|
http.StatusOK,
|
||||||
|
"GET",
|
||||||
|
"/book/{title}",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDKIntegrationWithRequestMethodInSpanName(t *testing.T) {
|
||||||
|
// prepare router & span recorder
|
||||||
|
router, sr := newSDKTestRouter("foobar", true, otelchi.WithRequestMethodInSpanName(true))
|
||||||
|
|
||||||
|
// define handler
|
||||||
|
router.HandleFunc("/user/{id:[0-9]+}", ok)
|
||||||
|
router.HandleFunc("/book/{title}", ok)
|
||||||
|
|
||||||
|
// execute requests
|
||||||
|
reqs := []*http.Request{
|
||||||
|
httptest.NewRequest("GET", "/user/123", nil),
|
||||||
|
httptest.NewRequest("GET", "/book/foo", nil),
|
||||||
|
}
|
||||||
|
executeRequests(router, reqs)
|
||||||
|
|
||||||
|
// get recorded spans & ensure the number is correct
|
||||||
|
recordedSpans := sr.Ended()
|
||||||
|
require.Len(t, sr.Ended(), len(reqs))
|
||||||
|
|
||||||
|
// check span values
|
||||||
|
checkSpans(t, recordedSpans, []spanValueCheck{
|
||||||
|
{
|
||||||
|
Name: "GET /user/{id:[0-9]+}",
|
||||||
|
Kind: trace.SpanKindServer,
|
||||||
|
Attributes: getSemanticAttributes(
|
||||||
|
"foobar",
|
||||||
|
http.StatusOK,
|
||||||
|
"GET",
|
||||||
|
"/user/{id:[0-9]+}",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /book/{title}",
|
||||||
|
Kind: trace.SpanKindServer,
|
||||||
|
Attributes: getSemanticAttributes(
|
||||||
|
"foobar",
|
||||||
|
http.StatusOK,
|
||||||
|
"GET",
|
||||||
|
"/book/{title}",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDKIntegrationEmptyHandlerDefaultStatusCode(t *testing.T) {
|
||||||
|
// prepare router and span recorder
|
||||||
|
router, sr := newSDKTestRouter("foobar", false)
|
||||||
|
|
||||||
|
// define routes
|
||||||
|
router.HandleFunc("/user/{id:[0-9]+}", func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
router.HandleFunc("/book/{title}", func(w http.ResponseWriter, r *http.Request) {})
|
||||||
|
router.HandleFunc("/not-found", http.NotFound)
|
||||||
|
|
||||||
|
// execute requests
|
||||||
|
reqs := []*http.Request{
|
||||||
|
httptest.NewRequest("GET", "/user/123", nil),
|
||||||
|
httptest.NewRequest("GET", "/book/foo", nil),
|
||||||
|
httptest.NewRequest("GET", "/not-found", nil),
|
||||||
|
}
|
||||||
|
executeRequests(router, reqs)
|
||||||
|
|
||||||
|
// get recorded spans
|
||||||
|
recordedSpans := sr.Ended()
|
||||||
|
|
||||||
|
// ensure that we have 3 recorded spans
|
||||||
|
require.Len(t, recordedSpans, len(reqs))
|
||||||
|
|
||||||
|
// ensure span values
|
||||||
|
checkSpans(t, recordedSpans, []spanValueCheck{
|
||||||
|
{
|
||||||
|
Name: "/user/{id:[0-9]+}",
|
||||||
|
Kind: trace.SpanKindServer,
|
||||||
|
Attributes: getSemanticAttributes(
|
||||||
|
"foobar",
|
||||||
|
http.StatusOK,
|
||||||
|
"GET",
|
||||||
|
"/user/{id:[0-9]+}",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "/book/{title}",
|
||||||
|
Kind: trace.SpanKindServer,
|
||||||
|
Attributes: getSemanticAttributes(
|
||||||
|
"foobar",
|
||||||
|
http.StatusOK,
|
||||||
|
"GET",
|
||||||
|
"/book/{title}",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "/not-found",
|
||||||
|
Kind: trace.SpanKindServer,
|
||||||
|
Attributes: getSemanticAttributes(
|
||||||
|
"foobar",
|
||||||
|
http.StatusNotFound,
|
||||||
|
"GET",
|
||||||
|
"/not-found",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDKIntegrationRootHandler(t *testing.T) {
|
||||||
|
// prepare router and span recorder
|
||||||
|
router, sr := newSDKTestRouter("foobar", true)
|
||||||
|
|
||||||
|
// define routes
|
||||||
|
router.HandleFunc("/", ok)
|
||||||
|
|
||||||
|
// execute requests
|
||||||
|
reqs := []*http.Request{
|
||||||
|
httptest.NewRequest("GET", "/", nil),
|
||||||
|
}
|
||||||
|
executeRequests(router, reqs)
|
||||||
|
|
||||||
|
// get recorded spans
|
||||||
|
recordedSpans := sr.Ended()
|
||||||
|
|
||||||
|
// ensure that we have 1 recorded span
|
||||||
|
require.Len(t, recordedSpans, 1)
|
||||||
|
|
||||||
|
// ensure span values
|
||||||
|
checkSpans(t, recordedSpans, []spanValueCheck{
|
||||||
|
{
|
||||||
|
Name: "/",
|
||||||
|
Kind: trace.SpanKindServer,
|
||||||
|
Attributes: getSemanticAttributes(
|
||||||
|
"foobar",
|
||||||
|
http.StatusOK,
|
||||||
|
"GET",
|
||||||
|
"/",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDKIntegrationWithTraceIDResponseHeader(t *testing.T) {
|
||||||
|
// prepare both sampled & non-sampled span context
|
||||||
|
spanCtxSampled := trace.NewSpanContext(trace.SpanContextConfig{
|
||||||
|
TraceID: [16]byte{1},
|
||||||
|
SpanID: [8]byte{1},
|
||||||
|
Remote: true,
|
||||||
|
TraceFlags: trace.FlagsSampled,
|
||||||
|
})
|
||||||
|
spanCtxNotSampled := trace.NewSpanContext(trace.SpanContextConfig{
|
||||||
|
TraceID: [16]byte{2},
|
||||||
|
SpanID: [8]byte{2},
|
||||||
|
Remote: true,
|
||||||
|
TraceFlags: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// define custom header key function
|
||||||
|
customHeaderKeyFunc := func() string {
|
||||||
|
return "X-Custom-Trace-ID"
|
||||||
|
}
|
||||||
|
|
||||||
|
// define test cases
|
||||||
|
testCases := []struct {
|
||||||
|
Name string
|
||||||
|
HeaderKeyFunc func() string
|
||||||
|
SpanContext trace.SpanContext
|
||||||
|
ExpTraceResponseIDKey string
|
||||||
|
ExpTraceResponseSampledKeyVal bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "Default Header Key, Trace Sampled",
|
||||||
|
HeaderKeyFunc: nil,
|
||||||
|
SpanContext: spanCtxSampled,
|
||||||
|
ExpTraceResponseIDKey: otelchi.DefaultTraceIDResponseHeaderKey,
|
||||||
|
ExpTraceResponseSampledKeyVal: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Default Header Key, Trace Not Sampled",
|
||||||
|
HeaderKeyFunc: nil,
|
||||||
|
SpanContext: spanCtxNotSampled,
|
||||||
|
ExpTraceResponseIDKey: otelchi.DefaultTraceIDResponseHeaderKey,
|
||||||
|
ExpTraceResponseSampledKeyVal: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Custom Header Key, Trace Sampled",
|
||||||
|
HeaderKeyFunc: customHeaderKeyFunc,
|
||||||
|
SpanContext: spanCtxSampled,
|
||||||
|
ExpTraceResponseIDKey: customHeaderKeyFunc(),
|
||||||
|
ExpTraceResponseSampledKeyVal: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Custom Header Key, Trace Not Sampled",
|
||||||
|
HeaderKeyFunc: customHeaderKeyFunc,
|
||||||
|
SpanContext: spanCtxNotSampled,
|
||||||
|
ExpTraceResponseIDKey: customHeaderKeyFunc(),
|
||||||
|
ExpTraceResponseSampledKeyVal: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.Name, func(t *testing.T) {
|
||||||
|
// configure router
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(
|
||||||
|
otelchi.Middleware(
|
||||||
|
"foobar",
|
||||||
|
otelchi.WithChiRoutes(router),
|
||||||
|
otelchi.WithTraceIDResponseHeader(testCase.HeaderKeyFunc),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
router.HandleFunc("/user/{id:[0-9]+}", ok)
|
||||||
|
router.HandleFunc("/book/{title}", ok)
|
||||||
|
|
||||||
|
// execute requests
|
||||||
|
r0 := httptest.NewRequest("GET", "/user/123", nil)
|
||||||
|
r0 = r0.WithContext(trace.ContextWithRemoteSpanContext(context.Background(), testCase.SpanContext))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, r0)
|
||||||
|
|
||||||
|
// check response headers
|
||||||
|
require.Equal(t, testCase.SpanContext.TraceID().String(), w.Header().Get(testCase.ExpTraceResponseIDKey))
|
||||||
|
require.Equal(t, fmt.Sprintf("%v", testCase.ExpTraceResponseSampledKeyVal), w.Header().Get(otelchi.DefaultTraceSampledResponseHeaderKey))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSDKIntegrationWithTraceResponseHeaders(t *testing.T) {
|
||||||
|
// prepare both sampled & non-sampled span context
|
||||||
|
spanCtxSampled := trace.NewSpanContext(trace.SpanContextConfig{
|
||||||
|
TraceID: [16]byte{1},
|
||||||
|
SpanID: [8]byte{1},
|
||||||
|
Remote: true,
|
||||||
|
TraceFlags: trace.FlagsSampled,
|
||||||
|
})
|
||||||
|
spanCtxNotSampled := trace.NewSpanContext(trace.SpanContextConfig{
|
||||||
|
TraceID: [16]byte{2},
|
||||||
|
SpanID: [8]byte{2},
|
||||||
|
Remote: true,
|
||||||
|
TraceFlags: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// define test cases
|
||||||
|
testCases := []struct {
|
||||||
|
Name string
|
||||||
|
TraceHeaderConfig otelchi.TraceHeaderConfig
|
||||||
|
SpanContext trace.SpanContext
|
||||||
|
ExpTraceResponseIDKey string
|
||||||
|
ExpTraceResponseSampledKey string
|
||||||
|
ExpTraceResponseSampledKeyVal bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "Default Trace Config, Trace Sampled",
|
||||||
|
TraceHeaderConfig: otelchi.TraceHeaderConfig{},
|
||||||
|
SpanContext: spanCtxSampled,
|
||||||
|
ExpTraceResponseIDKey: otelchi.DefaultTraceIDResponseHeaderKey,
|
||||||
|
ExpTraceResponseSampledKey: otelchi.DefaultTraceSampledResponseHeaderKey,
|
||||||
|
ExpTraceResponseSampledKeyVal: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Default Trace Config, Trace Not Sampled",
|
||||||
|
TraceHeaderConfig: otelchi.TraceHeaderConfig{},
|
||||||
|
SpanContext: spanCtxNotSampled,
|
||||||
|
ExpTraceResponseIDKey: otelchi.DefaultTraceIDResponseHeaderKey,
|
||||||
|
ExpTraceResponseSampledKey: otelchi.DefaultTraceSampledResponseHeaderKey,
|
||||||
|
ExpTraceResponseSampledKeyVal: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Custom Trace Config, Trace Sampled",
|
||||||
|
TraceHeaderConfig: otelchi.TraceHeaderConfig{
|
||||||
|
TraceIDHeader: "X-Custom-Trace-ID",
|
||||||
|
TraceSampledHeader: "X-Custom-Trace-Sampled",
|
||||||
|
},
|
||||||
|
SpanContext: spanCtxSampled,
|
||||||
|
ExpTraceResponseIDKey: "X-Custom-Trace-ID",
|
||||||
|
ExpTraceResponseSampledKey: "X-Custom-Trace-Sampled",
|
||||||
|
ExpTraceResponseSampledKeyVal: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Custom Trace Config, Trace Not Sampled",
|
||||||
|
TraceHeaderConfig: otelchi.TraceHeaderConfig{
|
||||||
|
TraceIDHeader: "X-Custom-Trace-ID",
|
||||||
|
TraceSampledHeader: "X-Custom-Trace-Sampled",
|
||||||
|
},
|
||||||
|
SpanContext: spanCtxNotSampled,
|
||||||
|
ExpTraceResponseIDKey: "X-Custom-Trace-ID",
|
||||||
|
ExpTraceResponseSampledKey: "X-Custom-Trace-Sampled",
|
||||||
|
ExpTraceResponseSampledKeyVal: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.Name, func(t *testing.T) {
|
||||||
|
// configure router
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(
|
||||||
|
otelchi.Middleware(
|
||||||
|
"foobar",
|
||||||
|
otelchi.WithChiRoutes(router),
|
||||||
|
otelchi.WithTraceResponseHeaders(testCase.TraceHeaderConfig),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
router.HandleFunc("/user/{id:[0-9]+}", ok)
|
||||||
|
router.HandleFunc("/book/{title}", ok)
|
||||||
|
|
||||||
|
// execute requests
|
||||||
|
r0 := httptest.NewRequest("GET", "/user/123", nil)
|
||||||
|
r0 = r0.WithContext(trace.ContextWithRemoteSpanContext(context.Background(), testCase.SpanContext))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, r0)
|
||||||
|
|
||||||
|
// check response headers
|
||||||
|
require.Equal(t, testCase.SpanContext.TraceID().String(), w.Header().Get(testCase.ExpTraceResponseIDKey))
|
||||||
|
require.Equal(t, fmt.Sprintf("%v", testCase.ExpTraceResponseSampledKeyVal), w.Header().Get(testCase.ExpTraceResponseSampledKey))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithPublicEndpoint(t *testing.T) {
|
||||||
|
// prepare router and span recorder
|
||||||
|
router, spanRecorder := newSDKTestRouter("foobar", true, otelchi.WithPublicEndpoint())
|
||||||
|
|
||||||
|
// prepare remote span context
|
||||||
|
remoteSpanCtx := trace.NewSpanContext(trace.SpanContextConfig{
|
||||||
|
TraceID: trace.TraceID{0x01},
|
||||||
|
SpanID: trace.SpanID{0x01},
|
||||||
|
Remote: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// prepare http request & inject remote span context into it
|
||||||
|
endpointURL := "/with/public/endpoint"
|
||||||
|
req := httptest.NewRequest(http.MethodGet, endpointURL, nil)
|
||||||
|
ctx := trace.ContextWithSpanContext(context.Background(), remoteSpanCtx)
|
||||||
|
(propagation.TraceContext{}).Inject(ctx, propagation.HeaderCarrier(req.Header))
|
||||||
|
|
||||||
|
// configure router handler
|
||||||
|
router.HandleFunc(endpointURL, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// get span from request context
|
||||||
|
span := trace.SpanFromContext(r.Context())
|
||||||
|
spanCtx := span.SpanContext()
|
||||||
|
|
||||||
|
// ensure it is not equal to the remote span context
|
||||||
|
require.False(t, spanCtx.Equal(remoteSpanCtx))
|
||||||
|
require.True(t, spanCtx.IsValid())
|
||||||
|
require.False(t, spanCtx.IsRemote())
|
||||||
|
})
|
||||||
|
|
||||||
|
// execute http request
|
||||||
|
executeRequests(router, []*http.Request{req})
|
||||||
|
|
||||||
|
// get recorded spans
|
||||||
|
recordedSpans := spanRecorder.Ended()
|
||||||
|
require.Len(t, recordedSpans, 1)
|
||||||
|
|
||||||
|
links := recordedSpans[0].Links()
|
||||||
|
require.Len(t, links, 1)
|
||||||
|
require.True(t, remoteSpanCtx.Equal(links[0].SpanContext))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithPublicEndpointFn(t *testing.T) {
|
||||||
|
// prepare remote span context
|
||||||
|
remoteSpanCtx := trace.NewSpanContext(trace.SpanContextConfig{
|
||||||
|
TraceID: trace.TraceID{0x01},
|
||||||
|
SpanID: trace.SpanID{0x01},
|
||||||
|
Remote: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// prepare test cases
|
||||||
|
testCases := []struct {
|
||||||
|
Name string
|
||||||
|
Fn func(r *http.Request) bool
|
||||||
|
HandlerAssert func(t *testing.T, spanCtx trace.SpanContext)
|
||||||
|
SpansAssert func(t *testing.T, spanCtx trace.SpanContext, spans []sdktrace.ReadOnlySpan)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
Name: "Function Always Return True",
|
||||||
|
Fn: func(r *http.Request) bool { return true },
|
||||||
|
HandlerAssert: func(t *testing.T, spanCtx trace.SpanContext) {
|
||||||
|
// ensure it is not equal to the remote span context
|
||||||
|
require.False(t, spanCtx.Equal(remoteSpanCtx))
|
||||||
|
require.True(t, spanCtx.IsValid())
|
||||||
|
|
||||||
|
// ensure it is not remote span
|
||||||
|
require.False(t, spanCtx.IsRemote())
|
||||||
|
},
|
||||||
|
SpansAssert: func(t *testing.T, spanCtx trace.SpanContext, spans []sdktrace.ReadOnlySpan) {
|
||||||
|
// ensure spans length
|
||||||
|
require.Len(t, spans, 1)
|
||||||
|
|
||||||
|
// ensure the span has been linked
|
||||||
|
links := spans[0].Links()
|
||||||
|
require.Len(t, links, 1)
|
||||||
|
require.True(t, remoteSpanCtx.Equal(links[0].SpanContext))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Function Always Return False",
|
||||||
|
Fn: func(r *http.Request) bool { return false },
|
||||||
|
HandlerAssert: func(t *testing.T, spanCtx trace.SpanContext) {
|
||||||
|
// ensure the span is child of the remote span
|
||||||
|
require.Equal(t, remoteSpanCtx.TraceID(), spanCtx.TraceID())
|
||||||
|
require.True(t, spanCtx.IsValid())
|
||||||
|
|
||||||
|
// ensure it is not remote span
|
||||||
|
require.False(t, spanCtx.IsRemote())
|
||||||
|
},
|
||||||
|
SpansAssert: func(t *testing.T, spanCtx trace.SpanContext, spans []sdktrace.ReadOnlySpan) {
|
||||||
|
// ensure spans length
|
||||||
|
require.Len(t, spans, 1, "unexpected span length")
|
||||||
|
|
||||||
|
// ensure the span has no links
|
||||||
|
links := spans[0].Links()
|
||||||
|
require.Len(t, links, 0)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// execute test cases
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.Name, func(t *testing.T) {
|
||||||
|
|
||||||
|
// prepare router and span recorder
|
||||||
|
router, spanRecorder := newSDKTestRouter(
|
||||||
|
"foobar",
|
||||||
|
true,
|
||||||
|
otelchi.WithPublicEndpointFn(testCase.Fn),
|
||||||
|
)
|
||||||
|
|
||||||
|
// prepare http request & inject remote span context into it
|
||||||
|
endpointURL := "/with/public/endpoint"
|
||||||
|
req := httptest.NewRequest(http.MethodGet, endpointURL, nil)
|
||||||
|
ctx := trace.ContextWithSpanContext(context.Background(), remoteSpanCtx)
|
||||||
|
(propagation.TraceContext{}).Inject(ctx, propagation.HeaderCarrier(req.Header))
|
||||||
|
|
||||||
|
// configure router handler
|
||||||
|
router.HandleFunc(endpointURL, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// assert handler
|
||||||
|
span := trace.SpanFromContext(r.Context())
|
||||||
|
testCase.HandlerAssert(t, span.SpanContext())
|
||||||
|
})
|
||||||
|
|
||||||
|
// execute http request
|
||||||
|
executeRequests(router, []*http.Request{req})
|
||||||
|
|
||||||
|
// assert recorded spans
|
||||||
|
testCase.SpansAssert(t, remoteSpanCtx, spanRecorder.Ended())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertSpan(t *testing.T, span sdktrace.ReadOnlySpan, name string, kind trace.SpanKind, attrs ...attribute.KeyValue) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
assert.Equal(t, name, span.Name())
|
||||||
|
assert.Equal(t, kind, span.SpanKind())
|
||||||
|
|
||||||
|
got := make(map[attribute.Key]attribute.Value, len(span.Attributes()))
|
||||||
|
for _, a := range span.Attributes() {
|
||||||
|
got[a.Key] = a.Value
|
||||||
|
}
|
||||||
|
for _, want := range attrs {
|
||||||
|
if !assert.Contains(t, got, want.Key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assert.Equal(t, want.Value, got[want.Key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ok(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSDKTestRouter(serverName string, withChiRoutes bool, opts ...otelchi.Option) (*chi.Mux, *tracetest.SpanRecorder) {
|
||||||
|
spanRecorder := tracetest.NewSpanRecorder()
|
||||||
|
tracerProvider := sdktrace.NewTracerProvider(
|
||||||
|
// set the tracer provider to always sample trace, this is important
|
||||||
|
// because if we don't set this, sometimes there are traces that
|
||||||
|
// won't be sampled (recorded), so we need to set this option
|
||||||
|
// to ensure every trace in this test is recorded.
|
||||||
|
sdktrace.WithSampler(sdktrace.AlwaysSample()),
|
||||||
|
)
|
||||||
|
tracerProvider.RegisterSpanProcessor(spanRecorder)
|
||||||
|
|
||||||
|
opts = append(opts, otelchi.WithTracerProvider(tracerProvider))
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
if withChiRoutes {
|
||||||
|
opts = append(opts, otelchi.WithChiRoutes(router))
|
||||||
|
}
|
||||||
|
router.Use(otelchi.Middleware(serverName, opts...))
|
||||||
|
|
||||||
|
return router, spanRecorder
|
||||||
|
}
|
||||||
|
|
||||||
|
type spanValueCheck struct {
|
||||||
|
Name string
|
||||||
|
Kind trace.SpanKind
|
||||||
|
Attributes []attribute.KeyValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSemanticAttributes(serverName string, httpStatusCode int, httpMethod, httpRoute string) []attribute.KeyValue {
|
||||||
|
return []attribute.KeyValue{
|
||||||
|
attribute.String("net.host.name", serverName),
|
||||||
|
attribute.Int("http.status_code", httpStatusCode),
|
||||||
|
attribute.String("http.method", httpMethod),
|
||||||
|
attribute.String("http.route", httpRoute),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkSpans(t *testing.T, spans []sdktrace.ReadOnlySpan, valueChecks []spanValueCheck) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
for i := 0; i < len(spans); i++ {
|
||||||
|
span := spans[i]
|
||||||
|
valueCheck := valueChecks[i]
|
||||||
|
assertSpan(t, span, valueCheck.Name, valueCheck.Kind, valueCheck.Attributes...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeRequests(router *chi.Mux, reqs []*http.Request) {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
for _, r := range reqs {
|
||||||
|
router.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
}
|
20
test/cases/version_test.go
Normal file
20
test/cases/version_test.go
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
package otelchi_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"toastielab.dev/toastie-stuff/otelchi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// regex taken from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
|
||||||
|
var versionRegex = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)` +
|
||||||
|
`(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)` +
|
||||||
|
`(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?` +
|
||||||
|
`(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`)
|
||||||
|
|
||||||
|
func TestVersionSemver(t *testing.T) {
|
||||||
|
v := otelchi.Version()
|
||||||
|
assert.NotNil(t, versionRegex.FindStringSubmatch(v), "version is not semver: %s", v)
|
||||||
|
}
|
34
test/infras/Dockerfile
Normal file
34
test/infras/Dockerfile
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
FROM debian:10.13
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
curl \
|
||||||
|
git \
|
||||||
|
unzip \
|
||||||
|
wget \
|
||||||
|
make \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Gobrew
|
||||||
|
ENV PATH="/root/.gobrew/current/bin:/root/.gobrew/bin:$PATH"
|
||||||
|
RUN curl -sL https://raw.githubusercontent.com/kevincobain2000/gobrew/v1.10.11/git.io.sh | bash
|
||||||
|
|
||||||
|
# Set Working Directory
|
||||||
|
WORKDIR /go/src/toastielab.dev/toastie-stuff/otelchi
|
||||||
|
|
||||||
|
# Install Multiple Go Versions
|
||||||
|
ARG GO_VERSIONS
|
||||||
|
COPY ./test/infras/install_go.sh ./test/infras/
|
||||||
|
RUN chmod +x ./test/infras/install_go.sh && sh ./test/infras/install_go.sh
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download -x
|
||||||
|
|
||||||
|
COPY ./test/infras/entrypoint.sh ./test/infras/
|
||||||
|
RUN chmod +x ./test/infras/entrypoint.sh
|
||||||
|
|
||||||
|
ENV GO_VERSIONS=$GO_VERSIONS
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENTRYPOINT [ "./test/infras/entrypoint.sh" ]
|
18
test/infras/entrypoint.sh
Normal file
18
test/infras/entrypoint.sh
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Terminate script immediately on any errors
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Define Go versions
|
||||||
|
go_versions=${GO_VERSIONS}
|
||||||
|
|
||||||
|
echo ${GO_VERSIONS}
|
||||||
|
|
||||||
|
# Iterate over each version
|
||||||
|
for version in $go_versions; do
|
||||||
|
# Set Go version using gobrew
|
||||||
|
gobrew use "$version"
|
||||||
|
|
||||||
|
# Execute Test as defined in the Makefile
|
||||||
|
make go-test
|
||||||
|
done
|
9
test/infras/install_go.sh
Normal file
9
test/infras/install_go.sh
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Define Go versions
|
||||||
|
go_versions=${GO_VERSIONS}
|
||||||
|
|
||||||
|
# Install each Go version using gobrew
|
||||||
|
for version in $go_versions; do
|
||||||
|
gobrew install "$version"
|
||||||
|
done
|
6
version.go
Normal file
6
version.go
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
package otelchi
|
||||||
|
|
||||||
|
// Version is the current release version of otelchi in use.
|
||||||
|
func Version() string {
|
||||||
|
return "0.10.0"
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue