Added the files for this package.

This commit is contained in:
Toastie 2025-03-22 23:29:15 +13:00
parent 4424b06532
commit d56374a8f7
Signed by: toastie_t0ast
GPG key ID: 0861BE54AD481DC7
32 changed files with 2179 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/.idea/

208
config.go Normal file
View 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
})
}

View 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
View 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
View 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
```

View 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
View 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
View 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
View 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"
}

View file

@ -0,0 +1,6 @@
run:
-make down
docker-compose up --build
down:
docker-compose down --remove-orphans

View 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`)
![Architecture Diagram](architecture.svg)
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.

File diff suppressed because one or more lines are too long

After

(image error) Size: 7.6 KiB

View 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"]

View 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)
}

View 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"]

View 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
}

View 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
)

View 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=

View file

@ -0,0 +1,7 @@
package utils
import "net/http"
func HealthCheckHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("It's working!"))
}

View 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
}

View 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
View 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
View 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
View 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
View 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
}

View 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
View 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)
}
}

View 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
View 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
View 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

View 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
View file

@ -0,0 +1,6 @@
package otelchi
// Version is the current release version of otelchi in use.
func Version() string {
return "0.10.0"
}