From d56374a8f7bc0b03bedaa9f861598680dacf2fef Mon Sep 17 00:00:00 2001
From: Toastie <toastie@toastiet0ast.com>
Date: Sat, 22 Mar 2025 23:29:15 +1300
Subject: [PATCH] Added the files for this package.

---
 .gitignore                                   |   1 +
 config.go                                    | 208 +++++
 examples/basic/Dockerfile                    |   7 +
 examples/basic/Makefile                      |   6 +
 examples/basic/README.md                     |  25 +
 examples/basic/docker-compose.yml            |  18 +
 examples/basic/go.mod                        |  25 +
 examples/basic/go.sum                        |  15 +
 examples/basic/main.go                       |  99 +++
 examples/multi-services/Makefile             |   6 +
 examples/multi-services/README.md            |  33 +
 examples/multi-services/architecture.svg     |   4 +
 examples/multi-services/back-svc/Dockerfile  |  13 +
 examples/multi-services/back-svc/main.go     |  54 ++
 examples/multi-services/front-svc/Dockerfile |  13 +
 examples/multi-services/front-svc/main.go    |  84 ++
 examples/multi-services/go.mod               |  36 +
 examples/multi-services/go.sum               |  25 +
 examples/multi-services/utils/healthcheck.go |   7 +
 examples/multi-services/utils/meter.go       |  39 +
 examples/multi-services/utils/tracer.go      |  45 ++
 go.mod                                       |  23 +
 go.sum                                       |  16 +
 metrics.go                                   |  88 +++
 middleware.go                                | 270 +++++++
 test/cases/middleware_test.go                | 150 ++++
 test/cases/sdk_test.go                       | 782 +++++++++++++++++++
 test/cases/version_test.go                   |  20 +
 test/infras/Dockerfile                       |  34 +
 test/infras/entrypoint.sh                    |  18 +
 test/infras/install_go.sh                    |   9 +
 version.go                                   |   6 +
 32 files changed, 2179 insertions(+)
 create mode 100644 .gitignore
 create mode 100644 config.go
 create mode 100644 examples/basic/Dockerfile
 create mode 100644 examples/basic/Makefile
 create mode 100644 examples/basic/README.md
 create mode 100644 examples/basic/docker-compose.yml
 create mode 100644 examples/basic/go.mod
 create mode 100644 examples/basic/go.sum
 create mode 100644 examples/basic/main.go
 create mode 100644 examples/multi-services/Makefile
 create mode 100644 examples/multi-services/README.md
 create mode 100644 examples/multi-services/architecture.svg
 create mode 100644 examples/multi-services/back-svc/Dockerfile
 create mode 100644 examples/multi-services/back-svc/main.go
 create mode 100644 examples/multi-services/front-svc/Dockerfile
 create mode 100644 examples/multi-services/front-svc/main.go
 create mode 100644 examples/multi-services/go.mod
 create mode 100644 examples/multi-services/go.sum
 create mode 100644 examples/multi-services/utils/healthcheck.go
 create mode 100644 examples/multi-services/utils/meter.go
 create mode 100644 examples/multi-services/utils/tracer.go
 create mode 100644 go.mod
 create mode 100644 go.sum
 create mode 100644 metrics.go
 create mode 100644 middleware.go
 create mode 100644 test/cases/middleware_test.go
 create mode 100644 test/cases/sdk_test.go
 create mode 100644 test/cases/version_test.go
 create mode 100644 test/infras/Dockerfile
 create mode 100644 test/infras/entrypoint.sh
 create mode 100644 test/infras/install_go.sh
 create mode 100644 version.go

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`)
+
+![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.
\ 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="&lt;mxfile host=&quot;app.diagrams.net&quot; modified=&quot;2022-07-23T01:04:25.341Z&quot; agent=&quot;5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36&quot; etag=&quot;ntYjDGgL3qct7J86SXXz&quot; version=&quot;20.0.4&quot; type=&quot;google&quot;&gt;&lt;diagram id=&quot;75aCuz8o8GoqpCSRtiU6&quot; name=&quot;Page-1&quot;&gt;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==&lt;/diagram&gt;&lt;/mxfile&gt;"><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"
+}