diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57f1cb2 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/.idea/ \ No newline at end of file diff --git a/config.go b/config.go new file mode 100644 index 0000000..153460a --- /dev/null +++ b/config.go @@ -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 + }) +} diff --git a/examples/basic/Dockerfile b/examples/basic/Dockerfile new file mode 100644 index 0000000..8abd79a --- /dev/null +++ b/examples/basic/Dockerfile @@ -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"] \ No newline at end of file diff --git a/examples/basic/Makefile b/examples/basic/Makefile new file mode 100644 index 0000000..9d8b697 --- /dev/null +++ b/examples/basic/Makefile @@ -0,0 +1,6 @@ +run: + -make down + docker-compose up --build + +down: + docker-compose down --remove-orphans \ No newline at end of file diff --git a/examples/basic/README.md b/examples/basic/README.md new file mode 100644 index 0000000..2a1d0a1 --- /dev/null +++ b/examples/basic/README.md @@ -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 +``` \ No newline at end of file diff --git a/examples/basic/docker-compose.yml b/examples/basic/docker-compose.yml new file mode 100644 index 0000000..ef28b96 --- /dev/null +++ b/examples/basic/docker-compose.yml @@ -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" \ No newline at end of file diff --git a/examples/basic/go.mod b/examples/basic/go.mod new file mode 100644 index 0000000..3f86248 --- /dev/null +++ b/examples/basic/go.mod @@ -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 +) \ No newline at end of file diff --git a/examples/basic/go.sum b/examples/basic/go.sum new file mode 100644 index 0000000..48d55b1 --- /dev/null +++ b/examples/basic/go.sum @@ -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= diff --git a/examples/basic/main.go b/examples/basic/main.go new file mode 100644 index 0000000..1121bb5 --- /dev/null +++ b/examples/basic/main.go @@ -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" +} diff --git a/examples/multi-services/Makefile b/examples/multi-services/Makefile new file mode 100644 index 0000000..9d8b697 --- /dev/null +++ b/examples/multi-services/Makefile @@ -0,0 +1,6 @@ +run: + -make down + docker-compose up --build + +down: + docker-compose down --remove-orphans \ No newline at end of file diff --git a/examples/multi-services/README.md b/examples/multi-services/README.md new file mode 100644 index 0000000..777be67 --- /dev/null +++ b/examples/multi-services/README.md @@ -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. \ No newline at end of file diff --git a/examples/multi-services/architecture.svg b/examples/multi-services/architecture.svg new file mode 100644 index 0000000..b661da1 --- /dev/null +++ b/examples/multi-services/architecture.svg @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Do not edit this file with editors other than diagrams.net --> +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="601px" height="171px" viewBox="-0.5 -0.5 601 171" content="<mxfile host="app.diagrams.net" modified="2022-07-23T01:04:25.341Z" agent="5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36" etag="ntYjDGgL3qct7J86SXXz" version="20.0.4" type="google"><diagram id="75aCuz8o8GoqpCSRtiU6" name="Page-1">3VfbctMwEP0aP6bjS+Kmj0mawgxlpjMBSh8VW7FVZK9R1o3D1yPZ61tuBEiB4SnaI2krnbM6W1veLCneKJbF7yHk0nLtsLC8W8t1Xdv39Y9BthXiOL5XIZESIWEtsBDfOIE2obkI+bq3EAEkiqwPBpCmPMAexpSCTX/ZCmT/r2Ys4nvAImByH30UIcYVOnavW/wtF1GMzf1uqpmE1YvpJuuYhbDpQN7c8mYKAKtRUsy4NOzVvFT77o7MNgdTPMVzNmzefViwyUceO5/S58fl0xTm2WBcZXlhMqcL02FxWzOgIE9DbpLYljfdxAL5ImOBmd1o0TUWYyJ15OjhClIkER19wyml5wp5cfTcTsOGriMOCUe11Utow5D4qyvIp3jTyuHbhMVdKa4JZFQCUZO6ZUkPiKifIG20xxEPddFQCApjiCBlct6i0z6L7Zp7gIy4e+aIWyKP5Qh9Znkh8HNn/GRSXY0oui0ocxlsKajOaQ53mnt9F8hVwE/cuX57TEUcT6xzD2upuGQoXvrnuLgw1weq2ZdIddmTzP+aQz0xWJekT/QCx82Kkrh6Xo+i6vcuZQmv8+mwSlnPHiiIe7bUbtgTkUkRpXocaP650oB5GUK7zYQmEhGGVb1wfSi2LPMZKTMQKZZ8jabW6PbU0yIrpM2tAXVlP17XR9+hfWWPxuPeWxyQW5ytLiV/MJfppHHc/hZYrda6ynbLoTnTr1eIs1chK1VWwEvwe8Z3AaNzvb7TNT2w43SOe8Dp/NcyOnePrSULvvwTZA39f42s4d/sCm0nOKcvmOCBK6Evblzo0r3CO7NXHFH4z/QK/1V7RaS4MbD/uFkMTz7Pge4Wvntzofaw82Rfrz14e0Uxk8LwsCuZ/mc+M8M8kZMAoatOqeQDrAUKMCotARGSA/Ih7Ngi5ChFymfNp4x9Ga+8+bFVepdxSh22HzSVKO13oTf/Dg==</diagram></mxfile>"><defs/><g><rect x="0" y="0" width="600" height="170" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/><path d="M 310 80 L 413.63 80" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 418.88 80 L 411.88 83.5 L 413.63 80 L 411.88 76.5 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 71px; margin-left: 369px;"><div data-drawio-colors="color: rgb(0, 0, 0); background-color: rgb(255, 255, 255); " style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; background-color: rgb(255, 255, 255); white-space: nowrap;"><font style="font-size: 12px;">/name</font></div></div></div></foreignObject><text x="369" y="74" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="11px" text-anchor="middle">/name</text></switch></g><rect x="190" y="50" width="120" height="60" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 80px; margin-left: 191px;"><div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">front-svc</div></div></div></foreignObject><text x="250" y="84" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle">front-svc</text></switch></g><rect x="420" y="50" width="120" height="60" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 118px; height: 1px; padding-top: 80px; margin-left: 421px;"><div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: normal; overflow-wrap: normal;">back-svc</div></div></div></foreignObject><text x="480" y="84" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle">back-svc</text></switch></g><path d="M 65 80 L 183.63 80" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="stroke"/><path d="M 188.88 80 L 181.88 83.5 L 183.63 80 L 181.88 76.5 Z" fill="rgb(0, 0, 0)" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe center; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 71px; margin-left: 125px;"><div data-drawio-colors="color: rgb(0, 0, 0); background-color: rgb(255, 255, 255); " style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 11px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; background-color: rgb(255, 255, 255); white-space: nowrap;"><font style="font-size: 12px;">/greet</font></div></div></div></foreignObject><text x="125" y="74" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="11px" text-anchor="middle">/greet</text></switch></g><ellipse cx="65" cy="57.5" rx="7.5" ry="7.5" fill="rgb(255, 255, 255)" stroke="rgb(0, 0, 0)" pointer-events="all"/><path d="M 65 65 L 65 90 M 65 70 L 50 70 M 65 70 L 80 70 M 65 90 L 50 110 M 65 90 L 80 110" fill="none" stroke="rgb(0, 0, 0)" stroke-miterlimit="10" pointer-events="all"/><g transform="translate(-0.5 -0.5)"><switch><foreignObject pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility" style="overflow: visible; text-align: left;"><div xmlns="http://www.w3.org/1999/xhtml" style="display: flex; align-items: unsafe flex-start; justify-content: unsafe center; width: 1px; height: 1px; padding-top: 117px; margin-left: 65px;"><div data-drawio-colors="color: rgb(0, 0, 0); " style="box-sizing: border-box; font-size: 0px; text-align: center;"><div style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.2; pointer-events: all; white-space: nowrap;">Client</div></div></div></foreignObject><text x="65" y="129" fill="rgb(0, 0, 0)" font-family="Helvetica" font-size="12px" text-anchor="middle">Client</text></switch></g></g><switch><g requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"/><a transform="translate(0,-5)" xlink:href="https://www.diagrams.net/doc/faq/svg-export-text-problems" target="_blank"><text text-anchor="middle" font-size="10px" x="50%" y="100%">Text is not SVG - cannot display</text></a></switch></svg> \ No newline at end of file diff --git a/examples/multi-services/back-svc/Dockerfile b/examples/multi-services/back-svc/Dockerfile new file mode 100644 index 0000000..448341f --- /dev/null +++ b/examples/multi-services/back-svc/Dockerfile @@ -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"] \ No newline at end of file diff --git a/examples/multi-services/back-svc/main.go b/examples/multi-services/back-svc/main.go new file mode 100644 index 0000000..5fd9de3 --- /dev/null +++ b/examples/multi-services/back-svc/main.go @@ -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) +} diff --git a/examples/multi-services/front-svc/Dockerfile b/examples/multi-services/front-svc/Dockerfile new file mode 100644 index 0000000..95cc048 --- /dev/null +++ b/examples/multi-services/front-svc/Dockerfile @@ -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"] \ No newline at end of file diff --git a/examples/multi-services/front-svc/main.go b/examples/multi-services/front-svc/main.go new file mode 100644 index 0000000..da818af --- /dev/null +++ b/examples/multi-services/front-svc/main.go @@ -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 +} diff --git a/examples/multi-services/go.mod b/examples/multi-services/go.mod new file mode 100644 index 0000000..fa7fc77 --- /dev/null +++ b/examples/multi-services/go.mod @@ -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 +) \ No newline at end of file diff --git a/examples/multi-services/go.sum b/examples/multi-services/go.sum new file mode 100644 index 0000000..576a522 --- /dev/null +++ b/examples/multi-services/go.sum @@ -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= diff --git a/examples/multi-services/utils/healthcheck.go b/examples/multi-services/utils/healthcheck.go new file mode 100644 index 0000000..2e78606 --- /dev/null +++ b/examples/multi-services/utils/healthcheck.go @@ -0,0 +1,7 @@ +package utils + +import "net/http" + +func HealthCheckHandler(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("It's working!")) +} diff --git a/examples/multi-services/utils/meter.go b/examples/multi-services/utils/meter.go new file mode 100644 index 0000000..bd80690 --- /dev/null +++ b/examples/multi-services/utils/meter.go @@ -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 +} diff --git a/examples/multi-services/utils/tracer.go b/examples/multi-services/utils/tracer.go new file mode 100644 index 0000000..90b9a0c --- /dev/null +++ b/examples/multi-services/utils/tracer.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5b42fb2 --- /dev/null +++ b/go.mod @@ -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 +) \ No newline at end of file diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..0ee495b --- /dev/null +++ b/go.sum @@ -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= diff --git a/metrics.go b/metrics.go new file mode 100644 index 0000000..ab678fe --- /dev/null +++ b/metrics.go @@ -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), + ), + ) +} diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..b876106 --- /dev/null +++ b/middleware.go @@ -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 +} diff --git a/test/cases/middleware_test.go b/test/cases/middleware_test.go new file mode 100644 index 0000000..41b102c --- /dev/null +++ b/test/cases/middleware_test.go @@ -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 +} diff --git a/test/cases/sdk_test.go b/test/cases/sdk_test.go new file mode 100644 index 0000000..57c1604 --- /dev/null +++ b/test/cases/sdk_test.go @@ -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) + } +} diff --git a/test/cases/version_test.go b/test/cases/version_test.go new file mode 100644 index 0000000..954be46 --- /dev/null +++ b/test/cases/version_test.go @@ -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) +} diff --git a/test/infras/Dockerfile b/test/infras/Dockerfile new file mode 100644 index 0000000..7a8c9cc --- /dev/null +++ b/test/infras/Dockerfile @@ -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" ] \ No newline at end of file diff --git a/test/infras/entrypoint.sh b/test/infras/entrypoint.sh new file mode 100644 index 0000000..aaf4f42 --- /dev/null +++ b/test/infras/entrypoint.sh @@ -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 \ No newline at end of file diff --git a/test/infras/install_go.sh b/test/infras/install_go.sh new file mode 100644 index 0000000..19feb43 --- /dev/null +++ b/test/infras/install_go.sh @@ -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 \ No newline at end of file diff --git a/version.go b/version.go new file mode 100644 index 0000000..8ab8ec4 --- /dev/null +++ b/version.go @@ -0,0 +1,6 @@ +package otelchi + +// Version is the current release version of otelchi in use. +func Version() string { + return "0.10.0" +}