Repository: elastic/apm-agent-go
Pull request #1410 (2023-04-06 14:33:37)

OpenTelemetry tracing bridge
This setups a bridge (our own SDK actually) which allows us to use the OpenTelemetry APIs alongside our agent.

dmathieu: Some areas are unfortunately impossible to test (setting tags on spans/transactions for example), because the data isn't readable within the test.
dmathieu: I may have missed some things, but it's hard to say without real world usage. So I am marking this PR as ready for review.

Patch:
diff --git a/docs/opentelemetry.asciidoc b/docs/opentelemetry.asciidoc
index a57f4aeb..5796c221 100644
--- a/docs/opentelemetry.asciidoc
+++ b/docs/opentelemetry.asciidoc
@@ -3,10 +3,70 @@
 
 The Elastic APM Go Agent provides wrappers to interact with the https://opentelemetry.io/[OpenTelemetry API].
 
-Metrics created through the OpenTelemetry API will be translated to Elastic APM metrics.
+Traces and metrics created through the OpenTelemetry API will be translated to their Elastic APM equivalent.
 
 [float]
-[[opentelemetry-init]]
+=== Initializing the tracing bridge
+
+The OpenTelemetry Tracing bridge is implemented as a Tracer Provider. Once
+setup, any span created through that provider will be set on the Elastic
+agent.  
+And the provider will try to find any existing transaction within the context.
+
+Note: our tracer provider bridge is an incomplete implementation of an
+OpenTelemetry SDK. It is a good solution meant to help migrate from our agent
+to OpenTelemetry, but shouldn't be considered as a long-term solution.
+
+To setup this tracer provider, you first need to import the `apmotel` package.
+
+[source,go]
+----
+import (
+	"go.elastic.co/apm/module/apmotel/v2"
+)
+----
+
+The apmotel package exposes a `NewTracerProvider` method, which returns an
+implementation of an OpenTelemetry Tracer Provider.
+
+
+[source,go]
+----
+provider, err := apmotel.NewTracerProvider()
+----
+
+By default, the tracer provider will find the default apm tracer. But you can
+specify a custom one with the `apmotel.WithAPMTracer` argument option.
+
+Once you have obtained this provider, you can configure it as your
+OpenTelemetry SDK implementation, and use the APIs normally.
+
+[source,go]
+----
+import (
+	"go.elastic.co/apm/v2"
+	"go.elastic.co/apm/module/apmotel/v2"
+
+	"go.opentelemetry.io/otel"
+)
+
+func main() {	
+	provider, err := apmotel.NewTracerProvider()
+	if err != nil {
+		log.Fatal(err)
+	}
+	otel.SetTracerProvider(provider)
+
+	tracer := otel.GetTracerProvider().Tracer("example")
+	// Start a new span to track some work, which will be sent to the Elastic APM tracer
+	ctx, span := tracer.Start(context.Background(), "my_work")
+	// Do something
+	span.End()
+}
+----
+
+[float]
+[[opentelemetry-metrics-init]]
 === Initializing the metrics exporter
 
 The OpenTelemetry Metrics bridge is implemented as a manual reader exporter.
diff --git a/module/apmotel/gatherer.go b/module/apmotel/gatherer.go
index 2d04777a..52df10e8 100644
--- a/module/apmotel/gatherer.go
+++ b/module/apmotel/gatherer.go
@@ -32,8 +32,8 @@ import (
 )
 
 // NewGatherer creates a new gatherer/exporter to bridge between agent metrics and OpenTelemetry
-func NewGatherer(opts ...Option) (Gatherer, error) {
-	cfg := newConfig(opts...)
+func NewGatherer(opts ...GathererOption) (Gatherer, error) {
+	cfg := newGathererConfig(opts...)
 	reader := metric.NewManualReader(cfg.manualReaderOptions()...)
 
 	return Gatherer{reader}, nil
diff --git a/module/apmotel/config.go b/module/apmotel/gatherer_config.go
similarity index 84%
rename from module/apmotel/config.go
rename to module/apmotel/gatherer_config.go
index 98339f9d..6a071f30 100644
--- a/module/apmotel/config.go
+++ b/module/apmotel/gatherer_config.go
@@ -34,24 +34,24 @@ var customHistogramBoundaries = []float64{
 	92681.9, 131072,
 }
 
-type config struct {
+type gathererConfig struct {
 	aggregation metric.AggregationSelector
 }
 
-type Option func(config) config
+type GathererOption func(gathererConfig) gathererConfig
 
-func newConfig(opts ...Option) config {
-	cfg := config{
+func newGathererConfig(opts ...GathererOption) gathererConfig {
+	cfg := gathererConfig{
 		aggregation: customAggregationSelector,
 	}
 	for _, opt := range opts {
-		opt(cfg)
+		cfg = opt(cfg)
 	}
 
 	return cfg
 }
 
-func (cfg config) manualReaderOptions() []metric.ManualReaderOption {
+func (cfg gathererConfig) manualReaderOptions() []metric.ManualReaderOption {
 	opts := []metric.ManualReaderOption{}
 	opts = append(opts, metric.WithAggregationSelector(cfg.aggregation))
 	return opts
@@ -60,8 +60,8 @@ func (cfg config) manualReaderOptions() []metric.ManualReaderOption {
 // WithAggregationSelector configure the Aggregation Selector the exporter will
 // use. If no AggregationSelector is provided the DefaultAggregationSelector is
 // used.
-func WithAggregationSelector(agg metric.AggregationSelector) Option {
-	return func(cfg config) config {
+func WithAggregationSelector(agg metric.AggregationSelector) GathererOption {
+	return func(cfg gathererConfig) gathererConfig {
 		cfg.aggregation = agg
 		return cfg
 	}
diff --git a/module/apmotel/go.mod b/module/apmotel/go.mod
index e0945cc2..331b6246 100644
--- a/module/apmotel/go.mod
+++ b/module/apmotel/go.mod
@@ -2,10 +2,12 @@ module go.elastic.co/apm/module/apmotel/v2
 
 require (
 	github.com/stretchr/testify v1.8.2
-	go.elastic.co/apm/v2 v2.0.0-00010101000000-000000000000
+	go.elastic.co/apm/module/apmhttp/v2 v2.3.0
+	go.elastic.co/apm/v2 v2.3.0
 	go.opentelemetry.io/otel v1.15.0-rc.2
 	go.opentelemetry.io/otel/metric v1.15.0-rc.2
 	go.opentelemetry.io/otel/sdk/metric v0.38.0-rc.2
+	go.opentelemetry.io/otel/trace v1.15.0-rc.2
 )
 
 require (
@@ -24,7 +26,6 @@ require (
 	github.com/prometheus/procfs v0.7.3 // indirect
 	go.elastic.co/fastjson v1.1.0 // indirect
 	go.opentelemetry.io/otel/sdk v1.15.0-rc.2 // indirect
-	go.opentelemetry.io/otel/trace v1.15.0-rc.2 // indirect
 	golang.org/x/mod v0.5.1 // indirect
 	golang.org/x/sys v0.6.0 // indirect
 	golang.org/x/tools v0.1.9 // indirect
@@ -35,4 +36,6 @@ require (
 
 replace go.elastic.co/apm/v2 => ../..
 
+replace go.elastic.co/apm/module/apmhttp/v2 => ../apmhttp
+
 go 1.20
diff --git a/module/apmotel/go.sum b/module/apmotel/go.sum
index eacbaf3a..039025af 100644
--- a/module/apmotel/go.sum
+++ b/module/apmotel/go.sum
@@ -74,6 +74,7 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f h1:OfiFi4JbukWwe3lzw+xunroH1mnC1e2Gy5cxNJApiSY=
 golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -96,6 +97,7 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
diff --git a/module/apmotel/span.go b/module/apmotel/span.go
new file mode 100644
index 00000000..8d7cdd53
--- /dev/null
+++ b/module/apmotel/span.go
@@ -0,0 +1,399 @@
+// Licensed to Elasticsearch B.V. under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Elasticsearch B.V. licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build go1.18
+// +build go1.18
+
+package apmotel // import "go.elastic.co/apm/module/apmotel/v2"
+
+import (
+	"fmt"
+	"net/http"
+	"net/url"
+	"reflect"
+	"runtime"
+	"sync"
+	"time"
+
+	"go.opentelemetry.io/otel/attribute"
+	"go.opentelemetry.io/otel/codes"
+	semconv "go.opentelemetry.io/otel/semconv/v1.17.0"
+	"go.opentelemetry.io/otel/trace"
+
+	"go.elastic.co/apm/module/apmhttp/v2"
+	"go.elastic.co/apm/v2"
+)
+
+type event struct {
+	Name       string
+	Attributes []attribute.KeyValue
+	Time       time.Time
+}
+
+type status struct {
+	Code        codes.Code
+	Description string
+}
+
+type span struct {
+	mu sync.Mutex
+
+	provider *tracerProvider
+
+	startTime   time.Time
+	attributes  []attribute.KeyValue
+	events      []event
+	spanContext trace.SpanContext
+	status      status
+	spanKind    trace.SpanKind
+
+	tx   *apm.Transaction
+	span *apm.Span
+}
+
+func (s *span) End(options ...trace.SpanEndOption) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	config := trace.NewSpanEndConfig(options...)
+
+	if !config.Timestamp().IsZero() {
+		duration := config.Timestamp().Sub(s.startTime)
+		if s.span != nil {
+			s.span.Duration = duration
+		} else {
+			s.tx.Duration = duration
+		}
+	}
+
+	var outcome string
+	switch s.status.Code {
+	case codes.Ok:
+		outcome = "success"
+	case codes.Error:
+		outcome = "failure"
+	case codes.Unset:
+		outcome = "unknown"
+	}
+
+	if s.span != nil {
+		s.setSpanAttributes()
+		s.span.Outcome = outcome
+		s.span.End()
+		return
+	}
+
+	s.setTransactionAttributes()
+	s.tx.Outcome = outcome
+	s.tx.End()
+}
+
+func (s *span) AddEvent(name string, opts ...trace.EventOption) {
+	c := trace.NewEventConfig(opts...)
+	e := event{Name: name, Attributes: c.Attributes(), Time: c.Timestamp()}
+
+	s.mu.Lock()
+	s.events = append(s.events, e)
+	s.mu.Unlock()
+}
+
+func (s *span) IsRecording() bool {
+	if s.span != nil {
+		return !s.span.Dropped()
+	}
+
+	return s.tx.Sampled()
+}
+
+func (s *span) RecordError(err error, opts ...trace.EventOption) {
+	opts = append(opts, trace.WithAttributes(
+		semconv.ExceptionType(typeStr(err)),
+		semconv.ExceptionMessage(err.Error()),
+	))
+
+	c := trace.NewEventConfig(opts...)
+	if c.StackTrace() {
+		opts = append(opts, trace.WithAttributes(
+			semconv.ExceptionStacktrace(recordStackTrace()),
+		))
+	}
+
+	s.AddEvent(semconv.ExceptionEventName, opts...)
+}
+
+func (s *span) SpanContext() trace.SpanContext {
+	return s.spanContext
+}
+
+func (s *span) SetStatus(code codes.Code, description string) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+	if s.status.Code > code {
+		return
+	}
+
+	status := status{Code: code}
+	if code == codes.Error {
+		status.Description = description
+	}
+
+	s.status = status
+}
+
+func (s *span) SetName(name string) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if s.span != nil {
+		s.span.Name = name
+	} else {
+		s.tx.Name = name
+	}
+}
+
+func (s *span) SetAttributes(kv ...attribute.KeyValue) {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	for _, a := range kv {
+		if !a.Valid() {
+			// Drop all invalid attributes.
+			continue
+		}
+
+		s.attributes = append(s.attributes, a)
+	}
+}
+
+func (s *span) TracerProvider() trace.TracerProvider {
+	return s.provider
+}
+
+// setSpanAttributes matches some span attributes with our custom ones.
+// See https://github.com/elastic/apm/blob/main/specs/agents/tracing-api-otel.md#span-type-sub-type-and-service-target
+func (s *span) setSpanAttributes() {
+	var (
+		dbContext  apm.DatabaseSpanContext
+		httpURL    string
+		httpMethod string
+		httpHost   string
+
+		netPeerPort string
+		netPeerName string
+
+		haveDBContext   bool
+		haveHTTPContext bool
+		haveHTTPHostTag bool
+
+		serviceTargetName string
+		serviceTargetType string
+	)
+
+	agentAttrs := make(map[string]interface{})
+	for _, v := range s.attributes {
+		agentAttrs[string(v.Key)] = v.Value.AsInterface()
+
+		switch v.Key {
+		case "component":
+			s.span.Subtype = v.Value.Emit()
+
+		case "db.system":
+			s.span.Type = "db"
+			s.span.Subtype = v.Value.Emit()
+			dbContext.Type = v.Value.Emit()
+			serviceTargetType = v.Value.Emit()
+			haveDBContext = true
+		case "db.instance":
+			dbContext.Instance = v.Value.Emit()
+			haveDBContext = true
+		case "db.statement":
+			dbContext.Statement = v.Value.Emit()
+			haveDBContext = true
+		case "db.user":
+			dbContext.User = v.Value.Emit()
+			haveDBContext = true
+		case "db.name":
+			serviceTargetName = v.Value.Emit()
+
+		case "messaging.system":
+			s.span.Type = "messaging"
+			s.span.Subtype = v.Value.Emit()
+			serviceTargetType = v.Value.Emit()
+		case "messaging.destination":
+			serviceTargetName = v.Value.Emit()
+
+		case "rpc.system":
+			s.span.Type = "external"
+			s.span.Subtype = v.Value.Emit()
+			serviceTargetType = v.Value.Emit()
+		case "rpc.service":
+			serviceTargetName = v.Value.Emit()
+		case "net.peer.port":
+			netPeerPort = v.Value.Emit()
+		case "net.peer.name":
+			netPeerName = v.Value.Emit()
+
+		case "http.url":
+			s.span.Type = "external"
+			s.span.Subtype = "http"
+			serviceTargetName = v.Value.Emit()
+			haveHTTPContext = true
+			httpURL = v.Value.Emit()
+		case "http.scheme":
+			s.span.Type = "external"
+			s.span.Subtype = "http"
+		case "http.method":
+			haveHTTPContext = true
+			httpMethod = v.Value.Emit()
+		case "http.host":
+			haveHTTPContext = true
+			haveHTTPHostTag = true
+			httpHost = v.Value.Emit()
+		}
+	}
+
+	if netPeerPort != "" && netPeerName != "" {
+		serviceTargetName = fmt.Sprintf("%s:%s", netPeerName, netPeerPort)
+	}
+
+	switch {
+	case haveHTTPContext:
+		url, err := url.Parse(httpURL)
+		if err == nil {
+			// handles the case where the url.Host hasn't been set.
+			// Tries obtaining the host value from the "http.host" tag.
+			// If not found, or if the url.Host has a value, it won't
+			// mutate the existing host.
+			if url.Host == "" && haveHTTPHostTag {
+				url.Host = httpHost
+			}
+			s.span.Context.SetHTTPRequest(&http.Request{
+				Method: httpMethod,
+				URL:    url,
+			})
+		}
+	case haveDBContext:
+		s.span.Context.SetDatabase(dbContext)
+	}
+
+	if serviceTargetType != "" || serviceTargetName != "" {
+		s.span.Context.SetServiceTarget(apm.ServiceTargetSpanContext{
+			Type: serviceTargetType,
+			Name: serviceTargetName,
+		})
+	}
+
+	s.span.Context.SetOTelAttributes(agentAttrs)
+	s.span.Context.SetOTelSpanKind(s.spanKind.String())
+}
+
+// setTransactionAttributes matches some of the transaction attributes with our custom ones
+// See https://github.com/elastic/apm/blob/main/specs/agents/tracing-api-otel.md#transaction-type
+func (s *span) setTransactionAttributes() {
+	var (
+		isHTTP      bool
+		isRPC       bool
+		isMessaging bool
+
+		httpMethod     string
+		httpStatusCode = -1
+		httpURL        string
+	)
+
+	agentAttrs := make(map[string]interface{})
+	for _, v := range s.attributes {
+		agentAttrs[string(v.Key)] = v.Value.AsInterface()
+
+		switch v.Key {
+		case "component":
+			s.tx.Type = v.Value.Emit()
+
+		case "http.method":
+			httpMethod = v.Value.Emit()
+		case "http.status_code":
+			if code := v.Value.AsInt64(); code > 0 {
+				httpStatusCode = int(code)
+			}
+		case "http.url":
+			httpURL = v.Value.Emit()
+			isHTTP = true
+		case "http.scheme":
+			isHTTP = true
+
+		case "rpc.system":
+			isRPC = true
+
+		case "messaging.system":
+			isMessaging = true
+
+		case "result":
+			s.tx.Result = v.Value.Emit()
+
+		case "user.id":
+			s.tx.Context.SetUserID(v.Value.Emit())
+		case "user.email":
+			s.tx.Context.SetUserEmail(v.Value.Emit())
+		case "user.username":
+			s.tx.Context.SetUsername(v.Value.Emit())
+		}
+	}
+
+	if s.tx.Type == "" {
+		if s.spanKind == trace.SpanKindServer && (isHTTP || isRPC) {
+			s.tx.Type = "request"
+		} else if s.spanKind == trace.SpanKindConsumer && isMessaging {
+			s.tx.Type = "messaging"
+		} else {
+			s.tx.Type = "unknown"
+		}
+	}
+
+	if s.tx.Result == "" && httpStatusCode != -1 {
+		s.tx.Result = apmhttp.StatusCodeResult(httpStatusCode)
+		s.tx.Context.SetHTTPStatusCode(httpStatusCode)
+	}
+
+	if isHTTP {
+		if uri, err := url.ParseRequestURI(httpURL); err == nil {
+			var req http.Request
+			req.ProtoMajor = 1 // Assume HTTP/1.1
+			req.ProtoMinor = 1
+			req.Method = httpMethod
+			req.URL = uri
+			s.tx.Context.SetHTTPRequest(&req)
+		}
+	}
+
+	s.tx.Context.SetOTelAttributes(agentAttrs)
+	s.tx.Context.SetOTelSpanKind(s.spanKind.String())
+}
+
+func typeStr(i interface{}) string {
+	t := reflect.TypeOf(i)
+	if t.PkgPath() == "" && t.Name() == "" {
+		// Likely a builtin type.
+		return t.String()
+	}
+	return fmt.Sprintf("%s.%s", t.PkgPath(), t.Name())
+}
+
+func recordStackTrace() string {
+	stackTrace := make([]byte, 2048)
+	n := runtime.Stack(stackTrace, false)
+
+	return string(stackTrace[0:n])
+}
diff --git a/module/apmotel/tracer.go b/module/apmotel/tracer.go
new file mode 100644
index 00000000..37ca3421
--- /dev/null
+++ b/module/apmotel/tracer.go
@@ -0,0 +1,104 @@
+// Licensed to Elasticsearch B.V. under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Elasticsearch B.V. licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build go1.18
+// +build go1.18
+
+package apmotel // import "go.elastic.co/apm/module/apmotel/v2"
+
+import (
+	"context"
+	"time"
+
+	"go.opentelemetry.io/otel/trace"
+
+	"go.elastic.co/apm/v2"
+)
+
+type tracer struct {
+	provider *tracerProvider
+}
+
+func newTracer(p *tracerProvider) *tracer {
+	return &tracer{p}
+}
+
+// Start forwards the call to APM Agent
+func (t *tracer) Start(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
+	config := trace.NewSpanStartConfig(opts...)
+
+	startTime := config.Timestamp()
+	if startTime.IsZero() {
+		startTime = time.Now()
+	}
+
+	s := &span{
+		provider:   t.provider,
+		attributes: config.Attributes(),
+		spanKind:   config.SpanKind(),
+		startTime:  startTime,
+	}
+
+	var psc trace.SpanContext
+	if config.NewRoot() {
+		ctx = trace.ContextWithSpanContext(ctx, psc)
+	} else {
+		// If not root, check if a *span is already present, if it's not,
+		// attempt to obtain an APM transaction from the context.
+		psc = trace.SpanContextFromContext(ctx)
+		s.spanContext = psc
+
+		var parent *span
+		var ok bool
+		if parent, ok = trace.SpanFromContext(ctx).(*span); !ok {
+			// Try to find a obtain an APM transaction from the agent context.
+			if tx := apm.TransactionFromContext(ctx); tx != nil {
+				parent = &span{tx: tx}
+			}
+		}
+
+		// Use the parent if it exists. Otherwise, we'll create a new
+		// transaction using the trace context from `psc`.
+		if parent != nil {
+			// This is a child span. Create a span, not a transaction.
+			// The parent may be a span or a transaction.
+			var tc apm.TraceContext
+			if parent.span != nil {
+				tc = parent.span.TraceContext()
+			} else {
+				tc = parent.tx.TraceContext()
+			}
+			s.span = parent.tx.StartSpanOptions(spanName, "", apm.SpanOptions{
+				Parent: tc,
+				Start:  startTime,
+			})
+			s.tx = parent.tx
+			return trace.ContextWithSpan(ctx, s), s
+		}
+	}
+
+	var tranOpts apm.TransactionOptions
+	if psc.HasTraceID() && psc.HasSpanID() {
+		tranOpts.TraceContext = apm.TraceContext{
+			Trace: [16]byte(psc.TraceID()),
+			Span:  [8]byte(psc.SpanID()),
+		}
+	}
+	s.tx = t.provider.apmTracer.StartTransactionOptions(spanName, "", tranOpts)
+
+	return trace.ContextWithSpan(ctx, s), s
+}
diff --git a/module/apmotel/tracer_provider.go b/module/apmotel/tracer_provider.go
new file mode 100644
index 00000000..6c644c6f
--- /dev/null
+++ b/module/apmotel/tracer_provider.go
@@ -0,0 +1,61 @@
+// Licensed to Elasticsearch B.V. under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Elasticsearch B.V. licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build go1.18
+// +build go1.18
+
+package apmotel // import "go.elastic.co/apm/module/apmotel/v2"
+
+import (
+	"sync"
+
+	"go.opentelemetry.io/otel/trace"
+
+	"go.elastic.co/apm/v2"
+)
+
+type tracerProvider struct {
+	mux sync.Mutex
+
+	apmTracer *apm.Tracer
+
+	tracers map[string]*tracer
+}
+
+// NewTracerProvider creates a new tracer provider which acts as a bridge with the Elastic Agent tracer.
+func NewTracerProvider(opts ...TracerProviderOption) (trace.TracerProvider, error) {
+	cfg := newTracerProviderConfig(opts...)
+	tp := &tracerProvider{
+		apmTracer: cfg.apmTracer,
+		tracers:   map[string]*tracer{},
+	}
+
+	return tp, nil
+}
+
+func (tp *tracerProvider) Tracer(name string, options ...trace.TracerOption) trace.Tracer {
+	tp.mux.Lock()
+	defer tp.mux.Unlock()
+
+	if t, ok := tp.tracers[name]; ok {
+		return t
+	}
+
+	t := newTracer(tp)
+	tp.tracers[name] = t
+	return t
+}
diff --git a/module/apmotel/tracer_provider_config.go b/module/apmotel/tracer_provider_config.go
new file mode 100644
index 00000000..4ee91145
--- /dev/null
+++ b/module/apmotel/tracer_provider_config.go
@@ -0,0 +1,52 @@
+// Licensed to Elasticsearch B.V. under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Elasticsearch B.V. licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build go1.18
+// +build go1.18
+
+package apmotel // import "go.elastic.co/apm/module/apmotel/v2"
+
+import (
+	"go.elastic.co/apm/v2"
+)
+
+type tracerProviderConfig struct {
+	apmTracer *apm.Tracer
+}
+
+type TracerProviderOption func(tracerProviderConfig) tracerProviderConfig
+
+func newTracerProviderConfig(opts ...TracerProviderOption) tracerProviderConfig {
+	cfg := tracerProviderConfig{}
+	for _, opt := range opts {
+		cfg = opt(cfg)
+	}
+
+	if cfg.apmTracer == nil {
+		cfg.apmTracer = apm.DefaultTracer()
+	}
+
+	return cfg
+}
+
+// WithAPMTracer configures a custom apm.Tracer which will be used as the tracing bridge.
+func WithAPMTracer(t *apm.Tracer) TracerProviderOption {
+	return func(cfg tracerProviderConfig) tracerProviderConfig {
+		cfg.apmTracer = t
+		return cfg
+	}
+}


Test patch:
diff --git a/module/apmotel/example_test.go b/module/apmotel/example_test.go
new file mode 100644
index 00000000..287cb1a5
--- /dev/null
+++ b/module/apmotel/example_test.go
@@ -0,0 +1,74 @@
+// Licensed to Elasticsearch B.V. under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Elasticsearch B.V. licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build go1.18
+// +build go1.18
+
+package apmotel
+
+import (
+	"context"
+	"fmt"
+	"log"
+	"time"
+
+	"go.opentelemetry.io/otel"
+
+	"go.elastic.co/apm/v2/transport/transporttest"
+)
+
+func Example() {
+	apmtracer, recorder := transporttest.NewRecorderTracer()
+	provider, err := NewTracerProvider(WithAPMTracer(apmtracer))
+	if err != nil {
+		log.Fatal(err)
+	}
+	otel.SetTracerProvider(provider)
+	defer apmtracer.Close()
+	tracer := provider.Tracer("example")
+
+	ctx := context.Background()
+	ctx, parent := tracer.Start(ctx, "parent")
+
+	for i := 0; i < 5; i++ {
+		id := fmt.Sprintf("span_%d", i)
+		_, child := tracer.Start(ctx, id)
+		time.Sleep(10 * time.Millisecond)
+		child.End()
+	}
+	parent.End()
+	apmtracer.Flush(nil)
+
+	payloads := recorder.Payloads()
+	if len(payloads.Transactions) != 1 {
+		panic(fmt.Errorf("expected 1 transaction, got %d", len(payloads.Transactions)))
+	}
+	for _, transaction := range payloads.Transactions {
+		fmt.Printf("transaction: %s/%s\n", transaction.Name, transaction.Type)
+	}
+	for _, span := range payloads.Spans {
+		fmt.Printf("span: %s/%s\n", span.Name, span.Type)
+	}
+
+	// Output:
+	// transaction: parent/unknown
+	// span: span_0/custom
+	// span: span_1/custom
+	// span: span_2/custom
+	// span: span_3/custom
+	// span: span_4/custom
+}
diff --git a/module/apmotel/config_test.go b/module/apmotel/gatherer_config_test.go
similarity index 84%
rename from module/apmotel/config_test.go
rename to module/apmotel/gatherer_config_test.go
index f443a19d..debb674e 100644
--- a/module/apmotel/config_test.go
+++ b/module/apmotel/gatherer_config_test.go
@@ -28,30 +28,30 @@ import (
 	"go.opentelemetry.io/otel/sdk/metric/aggregation"
 )
 
-func TestNewConfig(t *testing.T) {
+func TestNewGathererConfig(t *testing.T) {
 	aggregationSelector := func(metric.InstrumentKind) aggregation.Aggregation { return nil }
 
 	testCases := []struct {
 		name       string
-		options    []Option
-		wantConfig config
+		options    []GathererOption
+		wantConfig gathererConfig
 	}{
 		{
 			name:       "Default",
 			options:    nil,
-			wantConfig: config{},
+			wantConfig: gathererConfig{},
 		},
 		{
 			name: "WithAggregationSelector",
-			options: []Option{
+			options: []GathererOption{
 				WithAggregationSelector(aggregationSelector),
 			},
-			wantConfig: config{},
+			wantConfig: gathererConfig{},
 		},
 	}
 	for _, tt := range testCases {
 		t.Run(tt.name, func(t *testing.T) {
-			cfg := newConfig(tt.options...)
+			cfg := newGathererConfig(tt.options...)
 			// tested by TestConfigManualReaderOptions
 			cfg.aggregation = nil
 
@@ -65,18 +65,18 @@ func TestConfigManualReaderOptions(t *testing.T) {
 
 	testCases := []struct {
 		name            string
-		config          config
+		config          gathererConfig
 		wantOptionCount int
 	}{
 		{
 			name:            "Default",
-			config:          config{},
+			config:          gathererConfig{},
 			wantOptionCount: 1,
 		},
 
 		{
 			name:            "WithAggregationSelector",
-			config:          config{aggregation: aggregationSelector},
+			config:          gathererConfig{aggregation: aggregationSelector},
 			wantOptionCount: 1,
 		},
 	}
diff --git a/module/apmotel/span_test.go b/module/apmotel/span_test.go
new file mode 100644
index 00000000..80b02115
--- /dev/null
+++ b/module/apmotel/span_test.go
@@ -0,0 +1,701 @@
+// Licensed to Elasticsearch B.V. under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Elasticsearch B.V. licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build go1.18
+// +build go1.18
+
+package apmotel
+
+import (
+	"context"
+	"errors"
+	"net/url"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+
+	"go.opentelemetry.io/otel/attribute"
+	"go.opentelemetry.io/otel/codes"
+	"go.opentelemetry.io/otel/trace"
+
+	"go.elastic.co/apm/v2"
+	"go.elastic.co/apm/v2/model"
+	"go.elastic.co/apm/v2/transport/transporttest"
+)
+
+func TestSpanEnd(t *testing.T) {
+	for _, tt := range []struct {
+		name    string
+		getSpan func(context.Context, trace.Tracer) trace.Span
+
+		expectedSpans        []model.Span
+		expectedTransactions []model.Transaction
+	}{
+		{
+			name: "with a root span",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				ctx, s := tracer.Start(ctx, "name")
+				return s
+			},
+			expectedTransactions: []model.Transaction{
+				{
+					Name:    "name",
+					Type:    "unknown",
+					Outcome: "unknown",
+					OTel: &model.OTel{
+						SpanKind:   "unspecified",
+						Attributes: map[string]interface{}{},
+					},
+				},
+			},
+		},
+		{
+			name: "a root span with a span kind",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				ctx, s := tracer.Start(ctx, "name", trace.WithSpanKind(trace.SpanKindProducer))
+				return s
+			},
+			expectedTransactions: []model.Transaction{
+				{
+					Name:    "name",
+					Type:    "unknown",
+					Outcome: "unknown",
+					OTel: &model.OTel{
+						SpanKind:   "producer",
+						Attributes: map[string]interface{}{},
+					},
+				},
+			},
+		},
+		{
+			name: "a root span with a success status",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				ctx, s := tracer.Start(ctx, "name")
+				s.SetStatus(codes.Ok, "success")
+				return s
+			},
+			expectedTransactions: []model.Transaction{
+				{
+					Name:    "name",
+					Type:    "unknown",
+					Outcome: "success",
+					OTel: &model.OTel{
+						SpanKind:   "unspecified",
+						Attributes: map[string]interface{}{},
+					},
+				},
+			},
+		},
+		{
+			name: "a root span with an error status",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				ctx, s := tracer.Start(ctx, "name")
+				s.SetStatus(codes.Error, "error")
+				return s
+			},
+			expectedTransactions: []model.Transaction{
+				{
+					Name:    "name",
+					Type:    "unknown",
+					Outcome: "failure",
+					OTel: &model.OTel{
+						SpanKind:   "unspecified",
+						Attributes: map[string]interface{}{},
+					},
+				},
+			},
+		},
+		{
+			name: "a root span with component attribute",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				ctx, s := tracer.Start(ctx, "name", trace.WithAttributes(attribute.String("component", "my_monolith")))
+				return s
+			},
+			expectedTransactions: []model.Transaction{
+				{
+					Name:    "name",
+					Type:    "my_monolith",
+					Outcome: "unknown",
+					OTel: &model.OTel{
+						SpanKind: "unspecified",
+						Attributes: map[string]interface{}{
+							"component": "my_monolith",
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "a root span with http attributes",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				ctx, s := tracer.Start(ctx, "name",
+					trace.WithAttributes(
+						attribute.String("http.method", "GET"),
+						attribute.Int("http.status_code", 404),
+						attribute.String("http.url", "/"),
+					),
+					trace.WithSpanKind(trace.SpanKindServer),
+				)
+				return s
+			},
+			expectedTransactions: []model.Transaction{
+				{
+					Name:    "name",
+					Type:    "request",
+					Outcome: "unknown",
+					Result:  "HTTP 4xx",
+					Context: &model.Context{
+						Request: &model.Request{
+							URL: model.URL{
+								Protocol: "http",
+								Path:     "/",
+							},
+							Method:      "GET",
+							HTTPVersion: "1.1",
+						},
+						Response: &model.Response{
+							StatusCode: 404,
+						},
+					},
+					OTel: &model.OTel{
+						SpanKind: "server",
+						Attributes: map[string]interface{}{
+							"http.method":      "GET",
+							"http.status_code": float64(404),
+							"http.url":         "/",
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "a root span with unknown attribute",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				ctx, s := tracer.Start(ctx, "name", trace.WithAttributes(attribute.String("hello", "world")))
+				return s
+			},
+			expectedTransactions: []model.Transaction{
+				{
+					Name:    "name",
+					Type:    "unknown",
+					Outcome: "unknown",
+					OTel: &model.OTel{
+						SpanKind: "unspecified",
+						Attributes: map[string]interface{}{
+							"hello": "world",
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "with a child span",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				ctx, _ = tracer.Start(ctx, "parentSpan")
+				ctx, s := tracer.Start(ctx, "childSpan")
+				return s
+			},
+			expectedSpans: []model.Span{
+				{
+					Name:    "childSpan",
+					Type:    "custom",
+					Outcome: "unknown",
+					OTel: &model.OTel{
+						SpanKind:   "unspecified",
+						Attributes: map[string]interface{}{},
+					},
+				},
+			},
+		},
+		{
+			name: "a child span with a span kind",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				ctx, _ = tracer.Start(ctx, "parentSpan")
+				ctx, s := tracer.Start(ctx, "name", trace.WithSpanKind(trace.SpanKindServer))
+				return s
+			},
+			expectedSpans: []model.Span{
+				{
+					Name:    "name",
+					Type:    "custom",
+					Outcome: "unknown",
+					OTel: &model.OTel{
+						SpanKind:   "server",
+						Attributes: map[string]interface{}{},
+					},
+				},
+			},
+		},
+		{
+			name: "a child span with a success status",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				ctx, _ = tracer.Start(ctx, "parentSpan")
+				ctx, s := tracer.Start(ctx, "name")
+				s.SetStatus(codes.Ok, "success")
+				return s
+			},
+			expectedSpans: []model.Span{
+				{
+					Name:    "name",
+					Type:    "custom",
+					Outcome: "success",
+					OTel: &model.OTel{
+						SpanKind:   "unspecified",
+						Attributes: map[string]interface{}{},
+					},
+				},
+			},
+		},
+		{
+			name: "a child span with an error status",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				ctx, _ = tracer.Start(ctx, "parentSpan")
+				ctx, s := tracer.Start(ctx, "name")
+				s.SetStatus(codes.Error, "error")
+				return s
+			},
+			expectedSpans: []model.Span{
+				{
+					Name:    "name",
+					Type:    "custom",
+					Outcome: "failure",
+					OTel: &model.OTel{
+						SpanKind:   "unspecified",
+						Attributes: map[string]interface{}{},
+					},
+				},
+			},
+		},
+		{
+			name: "a child span with a component attribute",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				ctx, _ = tracer.Start(ctx, "parentSpan")
+				ctx, s := tracer.Start(ctx, "name", trace.WithAttributes(attribute.String("component", "my_service")))
+				return s
+			},
+			expectedSpans: []model.Span{
+				{
+					Name:    "name",
+					Type:    "custom",
+					Subtype: "my_service",
+					Outcome: "unknown",
+					OTel: &model.OTel{
+						SpanKind: "unspecified",
+						Attributes: map[string]interface{}{
+							"component": "my_service",
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "a child span with db attributes",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				ctx, _ = tracer.Start(ctx, "parentSpan")
+				ctx, s := tracer.Start(ctx, "name", trace.WithAttributes(
+					attribute.String("db.system", "postgres"),
+					attribute.String("db.instance", "instance_42"),
+					attribute.String("db.statement", "SELECT * FROM *;"),
+					attribute.String("db.user", "root"),
+					attribute.String("db.name", "database"),
+				))
+				return s
+			},
+			expectedSpans: []model.Span{
+				{
+					Name:    "name",
+					Type:    "db",
+					Subtype: "postgres",
+					Outcome: "unknown",
+					Context: &model.SpanContext{
+						Database: &model.DatabaseSpanContext{
+							Instance:  "instance_42",
+							Statement: "SELECT * FROM *;",
+							Type:      "postgres",
+							User:      "root",
+						},
+					},
+					OTel: &model.OTel{
+						SpanKind: "unspecified",
+						Attributes: map[string]interface{}{
+							"db.system":    "postgres",
+							"db.instance":  "instance_42",
+							"db.statement": "SELECT * FROM *;",
+							"db.user":      "root",
+							"db.name":      "database",
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "a child span with messaging attributes",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				ctx, _ = tracer.Start(ctx, "parentSpan")
+				ctx, s := tracer.Start(ctx, "name", trace.WithAttributes(
+					attribute.String("messaging.system", "kafka"),
+					attribute.String("messaging.destination", "example.com"),
+				))
+				return s
+			},
+			expectedSpans: []model.Span{
+				{
+					Name:    "name",
+					Type:    "messaging",
+					Subtype: "kafka",
+					Outcome: "unknown",
+					OTel: &model.OTel{
+						SpanKind: "unspecified",
+						Attributes: map[string]interface{}{
+							"messaging.system":      "kafka",
+							"messaging.destination": "example.com",
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "a child span with rpc attributes",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				ctx, _ = tracer.Start(ctx, "parentSpan")
+				ctx, s := tracer.Start(ctx, "name", trace.WithAttributes(
+					attribute.String("rpc.system", "net/http"),
+					attribute.String("rpc.service", "test"),
+					attribute.Int("net.peer.port", 8080),
+					attribute.String("net.peer.name", "example.com"),
+				))
+				return s
+			},
+			expectedSpans: []model.Span{
+				{
+					Name:    "name",
+					Type:    "external",
+					Subtype: "net/http",
+					Outcome: "unknown",
+					OTel: &model.OTel{
+						SpanKind: "unspecified",
+						Attributes: map[string]interface{}{
+							"rpc.system":    "net/http",
+							"rpc.service":   "test",
+							"net.peer.port": float64(8080),
+							"net.peer.name": "example.com",
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "a child span with http attributes",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				ctx, _ = tracer.Start(ctx, "parentSpan")
+				ctx, s := tracer.Start(ctx, "name", trace.WithAttributes(
+					attribute.String("http.url", "https://example.com"),
+					attribute.String("http.method", "GET"),
+					attribute.String("http.host", "localhost"),
+				))
+				return s
+			},
+			expectedSpans: []model.Span{
+				{
+					Name:    "name",
+					Type:    "external",
+					Subtype: "http",
+					Outcome: "unknown",
+					Context: &model.SpanContext{
+						Destination: &model.DestinationSpanContext{
+							Address: "example.com",
+							Port:    443,
+							Service: &model.DestinationServiceSpanContext{
+								Type:     "external",
+								Name:     "https://example.com",
+								Resource: "example.com:443",
+							},
+						},
+						HTTP: &model.HTTPSpanContext{
+							URL: &url.URL{
+								Scheme: "https",
+								Host:   "example.com",
+								Path:   "/",
+							},
+						},
+					},
+					OTel: &model.OTel{
+						SpanKind: "unspecified",
+						Attributes: map[string]interface{}{
+							"http.host":   "localhost",
+							"http.method": "GET",
+							"http.url":    "https://example.com",
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "a child span with unknown attribute",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				ctx, _ = tracer.Start(ctx, "parentSpan")
+				ctx, s := tracer.Start(ctx, "name", trace.WithAttributes(attribute.String("hello", "world")))
+				return s
+			},
+			expectedSpans: []model.Span{
+				{
+					Name:    "name",
+					Type:    "custom",
+					Outcome: "unknown",
+					OTel: &model.OTel{
+						SpanKind: "unspecified",
+						Attributes: map[string]interface{}{
+							"hello": "world",
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "with a grand child span",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				ctx, _ = tracer.Start(ctx, "parentSpan")
+				ctx, _ = tracer.Start(ctx, "childSpan")
+				ctx, s := tracer.Start(ctx, "grandChildSpan")
+				return s
+			},
+			expectedSpans: []model.Span{
+				{
+					Name:    "grandChildSpan",
+					Type:    "custom",
+					Outcome: "unknown",
+					OTel: &model.OTel{
+						SpanKind:   "unspecified",
+						Attributes: map[string]interface{}{},
+					},
+				},
+			},
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			apmTracer, recorder := transporttest.NewRecorderTracer()
+			tp, err := NewTracerProvider(WithAPMTracer(apmTracer))
+			assert.NoError(t, err)
+			tracer := newTracer(tp.(*tracerProvider))
+
+			ctx := context.Background()
+			s := tt.getSpan(ctx, tracer)
+			s.End()
+
+			apmTracer.Flush(nil)
+			payloads := recorder.Payloads()
+
+			if tt.expectedSpans != nil {
+				for i := range payloads.Spans {
+					payloads.Spans[i].ID = model.SpanID{}
+					payloads.Spans[i].TransactionID = model.SpanID{}
+					payloads.Spans[i].ParentID = model.SpanID{}
+					payloads.Spans[i].TraceID = model.TraceID{}
+					payloads.Spans[i].SampleRate = nil
+					payloads.Spans[i].Duration = 0
+					payloads.Spans[i].Timestamp = model.Time{}
+				}
+
+				assert.Equal(t, tt.expectedSpans, payloads.Spans)
+			}
+			if tt.expectedTransactions != nil {
+				for i := range payloads.Transactions {
+					payloads.Transactions[i].ID = model.SpanID{}
+					payloads.Transactions[i].TraceID = model.TraceID{}
+					payloads.Transactions[i].SampleRate = nil
+					payloads.Transactions[i].Duration = 0
+					payloads.Transactions[i].Timestamp = model.Time{}
+				}
+
+				assert.Equal(t, tt.expectedTransactions, payloads.Transactions)
+			}
+		})
+	}
+}
+
+func TestSpanAddEvent(t *testing.T) {
+	tp, err := NewTracerProvider()
+	assert.NoError(t, err)
+	tracer := newTracer(tp.(*tracerProvider))
+	_, s := tracer.Start(context.Background(), "mySpan")
+
+	assert.Equal(t, []event(nil), s.(*span).events)
+
+	now := time.Now()
+	s.AddEvent("test", trace.WithTimestamp(now))
+	assert.Equal(t, []event{
+		event{
+			Name: "test",
+			Time: now,
+		},
+	}, s.(*span).events)
+}
+
+func TestSpanRecording(t *testing.T) {
+	for _, tt := range []struct {
+		name          string
+		getSpan       func(context.Context, trace.Tracer) trace.Span
+		wantRecording bool
+	}{
+		{
+			name: "with a sampled root span",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				ctx, s := tracer.Start(ctx, "name")
+				return s
+			},
+			wantRecording: true,
+		},
+		{
+			name: "with a non-sampled root span",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				return &span{
+					tx: &apm.Transaction{},
+				}
+			},
+			wantRecording: false,
+		},
+		{
+			name: "with a non-dropped child span",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				ctx, _ = tracer.Start(ctx, "parentSpan")
+				ctx, s := tracer.Start(ctx, "childSpan")
+				return s
+			},
+			wantRecording: true,
+		},
+		{
+			name: "with a dropped child span",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				return &span{
+					span: &apm.Span{},
+				}
+			},
+			wantRecording: false,
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			tp, err := NewTracerProvider()
+			assert.NoError(t, err)
+			tracer := newTracer(tp.(*tracerProvider))
+
+			ctx := context.Background()
+			s := tt.getSpan(ctx, tracer)
+			assert.Equal(t, tt.wantRecording, s.IsRecording())
+		})
+	}
+}
+
+func TestSpanRecordError(t *testing.T) {
+	tp, err := NewTracerProvider()
+	assert.NoError(t, err)
+	tracer := newTracer(tp.(*tracerProvider))
+	_, s := tracer.Start(context.Background(), "mySpan")
+
+	assert.Equal(t, []event(nil), s.(*span).events)
+
+	now := time.Now()
+	s.RecordError(errors.New("test"), trace.WithTimestamp(now))
+	assert.Equal(t, []event{
+		event{
+			Name: "exception",
+			Attributes: []attribute.KeyValue{
+				attribute.String("exception.type", "*errors.errorString"),
+				attribute.String("exception.message", "test"),
+			},
+			Time: now,
+		},
+	}, s.(*span).events)
+}
+
+func TestSpanSetStatus(t *testing.T) {
+	tp, err := NewTracerProvider()
+	assert.NoError(t, err)
+	tracer := newTracer(tp.(*tracerProvider))
+	_, s := tracer.Start(context.Background(), "mySpan")
+
+	assert.Equal(t, status{Code: codes.Unset, Description: ""}, s.(*span).status)
+	s.SetStatus(codes.Error, "error")
+	assert.Equal(t, status{Code: codes.Error, Description: "error"}, s.(*span).status)
+
+	s.SetStatus(codes.Ok, "")
+	assert.Equal(t, status{Code: codes.Ok}, s.(*span).status)
+
+	s.SetStatus(codes.Error, "error")
+	assert.Equal(t, status{Code: codes.Ok}, s.(*span).status)
+}
+
+func TestSpanSetName(t *testing.T) {
+	for _, tt := range []struct {
+		name      string
+		getSpan   func(context.Context, trace.Tracer) trace.Span
+		checkName func(*testing.T, *span)
+	}{
+		{
+			name: "with a root span",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				ctx, s := tracer.Start(ctx, "name")
+				return s
+			},
+
+			checkName: func(t *testing.T, s *span) {
+				assert.Equal(t, s.tx.Name, "newName")
+			},
+		},
+		{
+			name: "with a child span",
+			getSpan: func(ctx context.Context, tracer trace.Tracer) trace.Span {
+				ctx, _ = tracer.Start(ctx, "parentSpan")
+				ctx, s := tracer.Start(ctx, "childSpan")
+				return s
+			},
+
+			checkName: func(t *testing.T, s *span) {
+				assert.Equal(t, s.tx.Name, "parentSpan")
+				assert.Equal(t, s.span.Name, "newName")
+			},
+		},
+	} {
+		t.Run(tt.name, func(t *testing.T) {
+			tp, err := NewTracerProvider()
+			assert.NoError(t, err)
+			tracer := newTracer(tp.(*tracerProvider))
+
+			ctx := context.Background()
+			s := tt.getSpan(ctx, tracer)
+
+			s.SetName("newName")
+		})
+	}
+}
+
+func TestSpanSetAttributes(t *testing.T) {
+	tp, err := NewTracerProvider()
+	assert.NoError(t, err)
+	tracer := newTracer(tp.(*tracerProvider))
+	_, s := tracer.Start(context.Background(), "mySpan")
+
+	assert.Equal(t, []attribute.KeyValue(nil), s.(*span).attributes)
+	s.SetAttributes(
+		attribute.String("string", "abc"),
+		attribute.Int("int", 42),
+	)
+	assert.Equal(t, []attribute.KeyValue{
+		attribute.String("string", "abc"),
+		attribute.Int("int", 42),
+	}, s.(*span).attributes)
+}
diff --git a/module/apmotel/tracer_provider_config_test.go b/module/apmotel/tracer_provider_config_test.go
new file mode 100644
index 00000000..719a5f0e
--- /dev/null
+++ b/module/apmotel/tracer_provider_config_test.go
@@ -0,0 +1,63 @@
+// Licensed to Elasticsearch B.V. under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Elasticsearch B.V. licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build go1.18
+// +build go1.18
+
+package apmotel
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"go.elastic.co/apm/v2"
+	"go.elastic.co/apm/v2/transport/transporttest"
+)
+
+func TestNewTracerProviderConfig(t *testing.T) {
+	apmTracer, _ := transporttest.NewRecorderTracer()
+
+	testCases := []struct {
+		name       string
+		options    []TracerProviderOption
+		wantConfig tracerProviderConfig
+	}{
+		{
+			name:    "Default",
+			options: nil,
+			wantConfig: tracerProviderConfig{
+				apmTracer: apm.DefaultTracer(),
+			},
+		},
+		{
+			name: "WithAPMTracer",
+			options: []TracerProviderOption{
+				WithAPMTracer(apmTracer),
+			},
+			wantConfig: tracerProviderConfig{
+				apmTracer: apmTracer,
+			},
+		},
+	}
+	for _, tt := range testCases {
+		t.Run(tt.name, func(t *testing.T) {
+			cfg := newTracerProviderConfig(tt.options...)
+			assert.Equal(t, tt.wantConfig, cfg)
+		})
+	}
+}
diff --git a/module/apmotel/tracer_provider_test.go b/module/apmotel/tracer_provider_test.go
new file mode 100644
index 00000000..84894621
--- /dev/null
+++ b/module/apmotel/tracer_provider_test.go
@@ -0,0 +1,42 @@
+// Licensed to Elasticsearch B.V. under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Elasticsearch B.V. licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build go1.18
+// +build go1.18
+
+package apmotel
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestNewTracerProvider(t *testing.T) {
+	tp, err := NewTracerProvider()
+	assert.NoError(t, err)
+	assert.NotNil(t, tp)
+}
+
+func TestTracerProviderTracer(t *testing.T) {
+	tp, err := NewTracerProvider()
+	assert.NoError(t, err)
+
+	tr := tp.Tracer("test")
+	assert.NotNil(t, tr)
+	assert.Equal(t, tr, tp.Tracer("test"))
+}
diff --git a/module/apmotel/tracer_test.go b/module/apmotel/tracer_test.go
new file mode 100644
index 00000000..e42c20b5
--- /dev/null
+++ b/module/apmotel/tracer_test.go
@@ -0,0 +1,116 @@
+// Licensed to Elasticsearch B.V. under one or more contributor
+// license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright
+// ownership. Elasticsearch B.V. licenses this file to you under
+// the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//go:build go1.18
+// +build go1.18
+
+package apmotel
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"go.opentelemetry.io/otel/trace"
+
+	"go.elastic.co/apm/v2"
+	"go.elastic.co/apm/v2/transport/transporttest"
+)
+
+func TestTracerStartStoresSpanInContext(t *testing.T) {
+	tp, err := NewTracerProvider()
+	assert.NoError(t, err)
+	tracer := newTracer(tp.(*tracerProvider))
+
+	ctx := context.Background()
+	ctx, s := tracer.Start(ctx, "name")
+
+	assert.Equal(t, s, trace.SpanFromContext(ctx))
+}
+
+func TestTracerStartTransaction(t *testing.T) {
+	tp, err := NewTracerProvider()
+	assert.NoError(t, err)
+	tracer := newTracer(tp.(*tracerProvider))
+
+	ctx := context.Background()
+	ctx, s := tracer.Start(ctx, "name")
+
+	assert.NotNil(t, s.(*span).tx)
+	assert.Nil(t, s.(*span).span)
+}
+
+func TestTracerStartTransactionWithParentContext(t *testing.T) {
+	tp, err := NewTracerProvider()
+	assert.NoError(t, err)
+	tracer := newTracer(tp.(*tracerProvider))
+
+	ctx := context.Background()
+	psc := trace.NewSpanContext(trace.SpanContextConfig{
+		TraceID: [16]byte{1},
+		SpanID:  [8]byte{42},
+	})
+	ctx = trace.ContextWithSpanContext(context.Background(), psc)
+
+	ctx, s := tracer.Start(ctx, "name")
+
+	assert.NotNil(t, s.(*span).tx)
+	assert.Nil(t, s.(*span).span)
+}
+
+func TestTracerStartChildSpan(t *testing.T) {
+	tp, err := NewTracerProvider()
+	assert.NoError(t, err)
+	tracer := newTracer(tp.(*tracerProvider))
+
+	ctx := context.Background()
+	ctx, ps := tracer.Start(ctx, "parentSpan")
+	ctx, cs := tracer.Start(ctx, "childSpan")
+
+	assert.Equal(t, ps.(*span).tx, cs.(*span).tx)
+	assert.NotNil(t, cs.(*span).span)
+}
+
+func TestTracerStartChildSpanFromTransactionInContext(t *testing.T) {
+	apmTracer, _ := transporttest.NewRecorderTracer()
+	tp, err := NewTracerProvider(WithAPMTracer(apmTracer))
+	assert.NoError(t, err)
+	tracer := newTracer(tp.(*tracerProvider))
+
+	ctx := context.Background()
+	tx := apmTracer.StartTransaction("parent", "")
+	ctx = apm.ContextWithTransaction(context.Background(), tx)
+	ctx, cs := tracer.Start(ctx, "childSpan")
+
+	assert.Equal(t, tx, cs.(*span).tx)
+	assert.NotNil(t, cs.(*span).span)
+}
+
+func TestTracerStartChildSpanWithNewRoot(t *testing.T) {
+	tp, err := NewTracerProvider()
+	assert.NoError(t, err)
+	tracer := newTracer(tp.(*tracerProvider))
+
+	ctx := context.Background()
+	ctx, ps := tracer.Start(ctx, "parentSpan")
+	ctx, cs := tracer.Start(ctx, "childSpan", trace.WithNewRoot())
+
+	assert.Nil(t, cs.(*span).span)
+	assert.NotNil(t, cs.(*span).tx)
+	assert.NotEqual(t, ps.(*span).tx, cs.(*span).tx)
+}