Skip to content

Commit 9d645c3

Browse files
minuk-devprestonvasquezdmathieu
authored
support mongo-driver's db metrics (#7983)
Co-authored-by: Preston Vasquez <[email protected]> Co-authored-by: Damien Mathieu <[email protected]>
1 parent 508e18a commit 9d645c3

File tree

7 files changed

+248
-12
lines changed

7 files changed

+248
-12
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
2121
- Add a `WithSpanNameFormatter` option to `go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo`. (#7986)
2222
- Add unmarshaling and validation for `AttributeType`, `AttributeNameValue`, `SimpleSpanProcessor`, `SimpleLogRecordProcessor`, `ZipkinSpanExporter`, `NameStringValuePair`, `InstrumentType`, `ExperimentalPeerInstrumentationServiceMappingElem`, `ExporterDefaultHistogramAggregation`, `PullMetricReader` to v1.0.0 model in `go.opentelemetry.io/contrib/otelconf`. (#8127)
2323
- Updated `go.opentelemetry.io/contrib/otelconf` to include the [v1.0.0-rc2](https://github.com/open-telemetry/opentelemetry-configuration/releases/tag/v1.0.0-rc.2) release candidate of schema which includes backwards incompatible changes. (#8026)
24+
- Support `db.client.operation.duration` metric for `go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo`. (#7983)
2425

2526
### Changed
2627

instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo/config.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package otelmongo // import "go.opentelemetry.io/contrib/instrumentation/go.mong
66
import (
77
"go.mongodb.org/mongo-driver/v2/event"
88
"go.opentelemetry.io/otel"
9+
"go.opentelemetry.io/otel/metric"
910
"go.opentelemetry.io/otel/trace"
1011
)
1112

@@ -14,8 +15,10 @@ const ScopeName = "go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mo
1415

1516
// config is used to configure the mongo tracer.
1617
type config struct {
18+
MeterProvider metric.MeterProvider
1719
TracerProvider trace.TracerProvider
1820

21+
Meter metric.Meter
1922
Tracer trace.Tracer
2023

2124
CommandAttributeDisabled bool
@@ -26,6 +29,7 @@ type config struct {
2629
// newConfig returns a config with all Options set.
2730
func newConfig(opts ...Option) config {
2831
cfg := config{
32+
MeterProvider: otel.GetMeterProvider(),
2933
TracerProvider: otel.GetTracerProvider(),
3034
CommandAttributeDisabled: true,
3135
}
@@ -43,6 +47,11 @@ func newConfig(opts ...Option) config {
4347
opt.apply(&cfg)
4448
}
4549

50+
cfg.Meter = cfg.MeterProvider.Meter(
51+
ScopeName,
52+
metric.WithInstrumentationVersion(Version()),
53+
)
54+
4655
cfg.Tracer = cfg.TracerProvider.Tracer(
4756
ScopeName,
4857
trace.WithInstrumentationVersion(Version()),
@@ -61,6 +70,16 @@ func (o optionFunc) apply(c *config) {
6170
o(c)
6271
}
6372

73+
// WithMeterProvider specifies a [metric.MeterProvider] to use for creating a Meter.
74+
// If none is specified, the global MeterProvider is used.
75+
func WithMeterProvider(provider metric.MeterProvider) Option {
76+
return optionFunc(func(cfg *config) {
77+
if provider != nil {
78+
cfg.MeterProvider = provider
79+
}
80+
})
81+
}
82+
6483
// SpanNameFormatterFunc is a function that resolves the span name given an
6584
// *event.CommandStartedEvent.
6685
type SpanNameFormatterFunc func(e *event.CommandStartedEvent) string

instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo/doc.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
// Package otelmongo instruments go.mongodb.org/mongo-driver/v2/mongo.
55
//
66
// `NewMonitor` will return an event.CommandMonitor which is used to trace
7-
// requests.
7+
// requests and collect its metrics.
88
//
99
// This code was originally based on the following:
1010
// - https://github.com/open-telemetry/opentelemetry-go-contrib/tree/323e373a6c15ae310bdd0617e3ed52d8cb8e4e6f/instrumentation/go.mongodb.org/mongo-driver/mongo/otelmongo

instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo/go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ require (
66
github.com/stretchr/testify v1.11.1
77
go.mongodb.org/mongo-driver/v2 v2.4.0
88
go.opentelemetry.io/otel v1.38.0
9+
go.opentelemetry.io/otel/metric v1.38.0
910
go.opentelemetry.io/otel/sdk v1.38.0
11+
go.opentelemetry.io/otel/sdk/metric v1.38.0
1012
go.opentelemetry.io/otel/trace v1.38.0
1113
)
1214

@@ -23,7 +25,6 @@ require (
2325
github.com/xdg-go/stringprep v1.0.4 // indirect
2426
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
2527
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
26-
go.opentelemetry.io/otel/metric v1.38.0 // indirect
2728
golang.org/x/crypto v0.45.0 // indirect
2829
golang.org/x/sync v0.18.0 // indirect
2930
golang.org/x/sys v0.38.0 // indirect
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright The OpenTelemetry Authors
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package otelmongo
5+
6+
import (
7+
"context"
8+
"testing"
9+
"time"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
"go.mongodb.org/mongo-driver/v2/bson"
14+
"go.mongodb.org/mongo-driver/v2/mongo"
15+
"go.mongodb.org/mongo-driver/v2/mongo/options"
16+
"go.mongodb.org/mongo-driver/v2/x/mongo/driver/drivertest"
17+
"go.opentelemetry.io/otel/sdk/metric"
18+
"go.opentelemetry.io/otel/sdk/metric/metricdata"
19+
)
20+
21+
const (
22+
testAddr = "mongodb://localhost:27017/?connect=direct"
23+
)
24+
25+
func TestMetricsOperationDuration(t *testing.T) {
26+
reader := metric.NewManualReader()
27+
provider := metric.NewMeterProvider(metric.WithReader(reader))
28+
29+
md := drivertest.NewMockDeployment()
30+
31+
ctx, cancel := context.WithTimeout(t.Context(), time.Second*3)
32+
defer cancel()
33+
34+
opts := options.Client()
35+
opts.Deployment = md //nolint:staticcheck // This method is the current documented way to set the mongodb mock. See https://github.com/mongodb/mongo-go-driver/blob/v2.0.0/x/mongo/driver/drivertest/opmsg_deployment_test.go#L24
36+
opts.Monitor = NewMonitor(
37+
WithMeterProvider(provider),
38+
WithCommandAttributeDisabled(false),
39+
)
40+
opts.ApplyURI(testAddr)
41+
42+
md.AddResponses([]bson.D{{{Key: "ok", Value: 1}}}...)
43+
client, err := mongo.Connect(opts)
44+
require.NoError(t, err)
45+
defer func() {
46+
err := client.Disconnect(t.Context())
47+
require.NoError(t, err)
48+
}()
49+
50+
// Perform an insert operation
51+
_, err = client.Database("test-database").Collection("test-collection").InsertOne(ctx, bson.D{{Key: "test-item", Value: "test-value"}})
52+
require.NoError(t, err)
53+
54+
// Collect metrics
55+
var rm metricdata.ResourceMetrics
56+
err = reader.Collect(ctx, &rm)
57+
require.NoError(t, err)
58+
59+
// Verify metrics were recorded
60+
require.Len(t, rm.ScopeMetrics, 1)
61+
scopeMetrics := rm.ScopeMetrics[0]
62+
assert.Equal(t, ScopeName, scopeMetrics.Scope.Name)
63+
64+
// Find the operation duration metric
65+
var foundDuration bool
66+
for _, m := range scopeMetrics.Metrics {
67+
if m.Name != "db.client.operation.duration" {
68+
continue
69+
}
70+
foundDuration = true
71+
histogram, ok := m.Data.(metricdata.Histogram[float64])
72+
assert.True(t, ok, "expected histogram data type")
73+
assert.NotEmpty(t, histogram.DataPoints)
74+
75+
// Check that attributes are present
76+
dp := histogram.DataPoints[0]
77+
attrs := dp.Attributes.ToSlice()
78+
hasDBSystem := false
79+
hasOperation := false
80+
for _, attr := range attrs {
81+
if attr.Key == "db.system.name" && attr.Value.AsString() == "mongodb" {
82+
hasDBSystem = true
83+
}
84+
if attr.Key == "db.operation.name" && attr.Value.AsString() == "insert" {
85+
hasOperation = true
86+
}
87+
}
88+
assert.True(t, hasDBSystem, "expected db.system.name attribute")
89+
assert.True(t, hasOperation, "expected db.operation.name attribute")
90+
}
91+
assert.True(t, foundDuration, "expected db.client.operation.duration metric")
92+
}
93+
94+
func TestMetricsOperationFailure(t *testing.T) {
95+
reader := metric.NewManualReader()
96+
provider := metric.NewMeterProvider(metric.WithReader(reader))
97+
98+
md := drivertest.NewMockDeployment()
99+
100+
ctx, cancel := context.WithTimeout(t.Context(), time.Second*3)
101+
defer cancel()
102+
103+
opts := options.Client()
104+
opts.Deployment = md //nolint:staticcheck // This method is the current documented way to set the mongodb mock. See https://github.com/mongodb/mongo-go-driver/blob/v2.0.0/x/mongo/driver/drivertest/opmsg_deployment_test.go#L24
105+
opts.Monitor = NewMonitor(
106+
WithMeterProvider(provider),
107+
WithCommandAttributeDisabled(true),
108+
)
109+
opts.ApplyURI(testAddr)
110+
111+
// Simulate an error response
112+
md.AddResponses([]bson.D{{{Key: "ok", Value: 0}, {Key: "errmsg", Value: "test error"}}}...)
113+
client, err := mongo.Connect(opts)
114+
require.NoError(t, err)
115+
defer func() {
116+
err := client.Disconnect(t.Context())
117+
require.NoError(t, err)
118+
}()
119+
120+
_, err = client.Database("test-database").Collection("test-collection").InsertOne(ctx, bson.D{{Key: "test-item", Value: "test-value"}})
121+
require.Error(t, err)
122+
123+
// Collect metrics
124+
var rm metricdata.ResourceMetrics
125+
err = reader.Collect(ctx, &rm)
126+
require.NoError(t, err)
127+
128+
// Verify metrics were recorded even for failed operations
129+
require.Len(t, rm.ScopeMetrics, 1)
130+
scopeMetrics := rm.ScopeMetrics[0]
131+
assert.NotEmpty(t, scopeMetrics.Metrics)
132+
}
133+
134+
func TestNewMonitorWithInvalidMeterProvider(t *testing.T) {
135+
// This test verifies that NewMonitor handles errors gracefully
136+
// even if metric creation fails. The function should not panic
137+
// and should return a valid monitor that can be used.
138+
139+
// Using a nil meter provider will use the global one, which should work
140+
monitor := NewMonitor()
141+
assert.NotNil(t, monitor)
142+
assert.NotNil(t, monitor.Started)
143+
assert.NotNil(t, monitor.Succeeded)
144+
assert.NotNil(t, monitor.Failed)
145+
}

instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo/mongo.go

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ import (
1212

1313
"go.mongodb.org/mongo-driver/v2/bson"
1414
"go.mongodb.org/mongo-driver/v2/event"
15+
"go.opentelemetry.io/otel"
1516
"go.opentelemetry.io/otel/attribute"
1617
"go.opentelemetry.io/otel/codes"
18+
"go.opentelemetry.io/otel/metric"
1719
semconv "go.opentelemetry.io/otel/semconv/v1.37.0"
20+
"go.opentelemetry.io/otel/semconv/v1.37.0/dbconv"
1821
"go.opentelemetry.io/otel/trace"
1922
)
2023

@@ -24,13 +27,15 @@ type spanKey struct {
2427
}
2528

2629
type monitor struct {
30+
ClientOperationDuration *dbconv.ClientOperationDuration
31+
2732
sync.Mutex
2833
spans map[spanKey]trace.Span
2934
cfg config
3035
}
3136

3237
func (m *monitor) Started(ctx context.Context, evt *event.CommandStartedEvent) {
33-
hostname, port := peerInfo(evt)
38+
hostname, port := peerInfo(evt.ConnectionID)
3439

3540
attrs := []attribute.KeyValue{
3641
semconv.DBSystemNameMongoDB,
@@ -65,12 +70,66 @@ func (m *monitor) Started(ctx context.Context, evt *event.CommandStartedEvent) {
6570
m.Unlock()
6671
}
6772

68-
func (m *monitor) Succeeded(_ context.Context, evt *event.CommandSucceededEvent) {
73+
func (m *monitor) Succeeded(ctx context.Context, evt *event.CommandSucceededEvent) {
6974
m.Finished(&evt.CommandFinishedEvent, nil)
75+
if m.ClientOperationDuration == nil {
76+
return
77+
}
78+
79+
hostname, port := peerInfo(evt.ConnectionID)
80+
attrs := attribute.NewSet(
81+
semconv.DBSystemNameMongoDB,
82+
// No need to add semconv.DBSystemMongoDB, it will be added by metrics recorder.
83+
semconv.DBOperationName(evt.CommandName),
84+
semconv.DBNamespace(evt.DatabaseName),
85+
semconv.NetworkPeerAddress(hostname),
86+
semconv.NetworkPeerPort(port),
87+
semconv.NetworkTransportTCP,
88+
// `db.response.status_code` is excluded for succeeded events.
89+
// Succeeded processes an [go.mongodb.org/mongo-driver/v2/event.CommandSucceededEvent] for OTel,
90+
// including collecting metrics. The status code metric is excluded since MongoDB server indicates
91+
// a successful operation with {ok: 1}, which doesn't map to a traditional status code.
92+
)
93+
// TODO: db.query.text attribute is currently disabled by default.
94+
// Because event does not provide the query text directly.
95+
// command := m.extractCommand(evt)
96+
// attrs = append(attrs, semconv.DBQueryText(sanitizeCommand(evt.Command)))
97+
98+
m.ClientOperationDuration.RecordSet(
99+
ctx,
100+
evt.Duration.Seconds(),
101+
attrs,
102+
)
70103
}
71104

72-
func (m *monitor) Failed(_ context.Context, evt *event.CommandFailedEvent) {
105+
func (m *monitor) Failed(ctx context.Context, evt *event.CommandFailedEvent) {
73106
m.Finished(&evt.CommandFinishedEvent, evt.Failure)
107+
if m.ClientOperationDuration == nil {
108+
return
109+
}
110+
111+
hostname, port := peerInfo(evt.ConnectionID)
112+
attrs := attribute.NewSet(
113+
semconv.DBSystemNameMongoDB,
114+
semconv.DBOperationName(evt.CommandName),
115+
semconv.NetworkPeerAddress(hostname),
116+
semconv.NetworkPeerPort(port),
117+
semconv.NetworkTransportTCP,
118+
// TODO: The status code should not be static, but reflect server behavior.
119+
// Assert the error as [go.mongodb.org/mongo-driver/v2/x/mongo/driver.Error] and pull the code from there.
120+
// ref. https://jira.mongodb.org/browse/GODRIVER-3690
121+
semconv.ErrorType(evt.Failure),
122+
)
123+
// TODO: db.query.text attribute is currently disabled by default.
124+
// Because event does not provide the query text directly.
125+
// command := m.extractCommand(evt)
126+
// attrs = append(attrs, semconv.DBQueryText(sanitizeCommand(evt.Command)))
127+
128+
m.ClientOperationDuration.RecordSet(
129+
ctx,
130+
evt.Duration.Seconds(),
131+
attrs,
132+
)
74133
}
75134

76135
func (m *monitor) Finished(evt *event.CommandFinishedEvent, err error) {
@@ -125,9 +184,23 @@ func extractCollection(evt *event.CommandStartedEvent) (string, error) {
125184
// NewMonitor creates a new mongodb event CommandMonitor.
126185
func NewMonitor(opts ...Option) *event.CommandMonitor {
127186
cfg := newConfig(opts...)
187+
var clientOperationDuration *dbconv.ClientOperationDuration
188+
operationDuration, err := dbconv.NewClientOperationDuration(
189+
cfg.Meter,
190+
metric.WithExplicitBucketBoundaries(0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10),
191+
)
192+
if err != nil {
193+
clientOperationDuration = nil
194+
otel.Handle(err)
195+
} else {
196+
clientOperationDuration = &operationDuration
197+
}
198+
128199
m := &monitor{
129200
spans: make(map[spanKey]trace.Span),
130201
cfg: cfg,
202+
203+
ClientOperationDuration: clientOperationDuration,
131204
}
132205
return &event.CommandMonitor{
133206
Started: m.Started,
@@ -137,12 +210,12 @@ func NewMonitor(opts ...Option) *event.CommandMonitor {
137210
}
138211

139212
// peerInfo will parse the hostname and port from the mongo connection ID.
140-
func peerInfo(evt *event.CommandStartedEvent) (hostname string, port int) {
213+
func peerInfo(connectionID string) (hostname string, port int) {
141214
defaultMongoPort := 27017
142-
hostname, portStr, err := net.SplitHostPort(evt.ConnectionID)
215+
hostname, portStr, err := net.SplitHostPort(connectionID)
143216
if err != nil {
144217
// If parsing fails, assume default MongoDB port and return the entire ConnectionID as hostname
145-
hostname = evt.ConnectionID
218+
hostname = connectionID
146219
port = defaultMongoPort
147220
return hostname, port
148221
}

instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo/mongo_test.go

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -376,10 +376,7 @@ func TestPeerInfo(t *testing.T) {
376376
t.Run(tc.name, func(t *testing.T) {
377377
t.Parallel()
378378

379-
evt := &event.CommandStartedEvent{
380-
ConnectionID: tc.connectionID,
381-
}
382-
host, port := peerInfo(evt)
379+
host, port := peerInfo(tc.connectionID)
383380
assert.Equal(t, tc.expectedHost, host)
384381
assert.Equal(t, tc.expectedPort, port)
385382
})

0 commit comments

Comments
 (0)