Skip to content

Commit b27545f

Browse files
authored
Migrate db.sql.latency metric to db.client.operation.duration (XSAM#480)
Part of XSAM#388
1 parent 757add3 commit b27545f

13 files changed

+864
-146
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
1616
- `database`: Emit `db.query.text` attribute.
1717
- by default: Emit `db.statement` attribute.
1818

19+
- New `db.client.operation.duration` metric following OpenTelemetry semantic conventions. (#480)
20+
- Support for configuring metrics behavior based on `OTEL_SEMCONV_STABILITY_OPT_IN` setting. (#480)
21+
22+
- `database/dup`: Emit both legacy latency and new duration `db.client.operation.duration` metrics.
23+
- `database`: Emit new duration `db.client.operation.duration` metric.
24+
- by default: Emit only the legacy latency metric.
25+
1926
## [0.38.0] - 2025-03-26
2027

2128
### Added

README.md

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,17 +63,56 @@ Use [`SpanOptions`](https://pkg.go.dev/github.com/XSAM/otelsql#SpanOptions) to a
6363

6464
## Metric Instruments
6565

66-
| Name | Description | Units | Instrument Type | Value Type | Attribute Key(s) | Attribute Values |
67-
| -------------------------------------------- | ---------------------------------------------------------------- | ----- | -------------------- | ---------- | ---------------- | ---------------------------------- |
68-
| db.sql.latency | The latency of calls in milliseconds | ms | Histogram | float64 | status | ok, error |
69-
| | | | | | method | method name, like `sql.conn.query` |
70-
| db.sql.connection.max_open | Maximum number of open connections to the database | | Asynchronous Gauge | int64 | | |
71-
| db.sql.connection.open | The number of established connections both in use and idle | | Asynchronous Gauge | int64 | status | idle, inuse |
72-
| db.sql.connection.wait | The total number of connections waited for | | Asynchronous Counter | int64 | | |
73-
| db.sql.connection.wait_duration | The total time blocked waiting for a new connection | ms | Asynchronous Counter | float64 | | |
74-
| db.sql.connection.closed_max_idle | The total number of connections closed due to SetMaxIdleConns | | Asynchronous Counter | int64 | | |
75-
| db.sql.connection.closed_max_idle_time | The total number of connections closed due to SetConnMaxIdleTime | | Asynchronous Counter | int64 | | |
76-
| db.sql.connection.closed_max_lifetime | The total number of connections closed due to SetConnMaxLifetime | | Asynchronous Counter | int64 | | |
66+
Two types of metrics are provided depending on the semantic convention stability setting:
67+
68+
### Legacy Metrics (default)
69+
- **db.sql.latency**: The latency of calls in milliseconds
70+
- Unit: milliseconds
71+
- Attributes: `method` (method name), `status` (ok, error)
72+
73+
### OpenTelemetry Semantic Convention Metrics
74+
- [**db.client.operation.duration**](https://github.com/open-telemetry/semantic-conventions/blob/v1.32.0/docs/database/database-metrics.md#metric-dbclientoperationduration): Duration of database client operations
75+
- Unit: seconds
76+
- Attributes: [`db.operation.name`](https://github.com/open-telemetry/semantic-conventions/blob/v1.32.0/docs/attributes-registry/db.md#db-operation-name) (method name), [`error.type`](https://github.com/open-telemetry/semantic-conventions/blob/v1.32.0/docs/attributes-registry/error.md#error-type) (if error occurs)
77+
78+
### Connection Statistics Metrics (from Go's sql.DBStats)
79+
- **db.sql.connection.max_open**: Maximum number of open connections to the database
80+
- **db.sql.connection.open**: The number of established connections
81+
- Attributes: `status` (idle, inuse)
82+
- **db.sql.connection.wait**: The total number of connections waited for
83+
- **db.sql.connection.wait_duration**: The total time blocked waiting for a new connection (ms)
84+
- **db.sql.connection.closed_max_idle**: The total number of connections closed due to SetMaxIdleConns
85+
- **db.sql.connection.closed_max_idle_time**: The total number of connections closed due to SetConnMaxIdleTime
86+
- **db.sql.connection.closed_max_lifetime**: The total number of connections closed due to SetConnMaxLifetime
87+
88+
### Metric Semantic Convention Stability
89+
90+
The instrumentation supports different OpenTelemetry semantic convention stability levels, configured through the `OTEL_SEMCONV_STABILITY_OPT_IN` environment variable:
91+
92+
| Setting | Metrics Emitted | Description |
93+
|---------|----------------|-------------|
94+
| empty (default) | `db.sql.latency` only | Only uses legacy metric|
95+
| `database/dup` | Both `db.sql.latency` and `db.client.operation.duration` | Emits both legacy and new OTel metric formats |
96+
| `database` | `db.client.operation.duration` only | Only uses the new OTel semantic convention metric |
97+
98+
Connection statistics metrics (`db.sql.connection.*`) are always emitted regardless of the stability setting.
99+
100+
This allows users to gradually migrate to the new OpenTelemetry semantic conventions while maintaining backward compatibility with existing dashboards and alerts.
101+
102+
## Error Type Attribution
103+
104+
When errors occur during database operations, the `error.type` attribute is automatically populated with the type of the error. This provides more detailed information for debugging and monitoring:
105+
106+
1. **For standard driver errors**: Special handling for common driver errors:
107+
- `database/sql/driver.ErrBadConn`
108+
- `database/sql/driver.ErrSkip`
109+
- `database/sql/driver.ErrRemoveArgument`
110+
111+
2. **For custom errors**: The fully qualified type name is used (e.g., `github.com/your/package.CustomError`).
112+
113+
3. **For built-in errors**: The type name is used (e.g., `*errors.errorString` for errors created with `errors.New()`).
114+
115+
**Note**: The `error.type` attribute is only available when using the new stable OpenTelemetry semantic convention metrics. This requires setting `OTEL_SEMCONV_STABILITY_OPT_IN` to either `database/dup` or `database`. With the default setting (empty), which only uses legacy metrics, the `error.type` attribute will not be populated.
77116

78117
## Compatibility
79118

conn_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ func TestOtConn_Ping(t *testing.T) {
218218
ctx, sr, tracer, dummySpan := prepareTraces(tc.noParentSpan)
219219

220220
// New conn
221-
cfg := newMockConfig(t, tracer)
221+
cfg := newMockConfig(t, tracer, nil)
222222
cfg.SpanOptions.Ping = tc.pingOption
223223
cfg.AttributesGetter = tc.attributesGetter
224224
cfg.InstrumentAttributesGetter = InstrumentAttributesGetter(tc.attributesGetter)
@@ -317,7 +317,7 @@ func TestOtConn_ExecContext(t *testing.T) {
317317
ctx, sr, tracer, dummySpan := prepareTraces(tc.noParentSpan)
318318

319319
// New conn
320-
cfg := newMockConfig(t, tracer)
320+
cfg := newMockConfig(t, tracer, nil)
321321
cfg.SpanOptions.DisableQuery = tc.disableQuery
322322
cfg.SpanOptions.SpanFilter = spanFilterFn
323323
cfg.AttributesGetter = tc.attributesGetter
@@ -419,7 +419,7 @@ func TestOtConn_QueryContext(t *testing.T) {
419419
ctx, sr, tracer, dummySpan := prepareTraces(tc.noParentSpan)
420420

421421
// New conn
422-
cfg := newMockConfig(t, tracer)
422+
cfg := newMockConfig(t, tracer, nil)
423423
cfg.SpanOptions.DisableQuery = tc.disableQuery
424424
cfg.SpanOptions.OmitConnQuery = omitConnQuery
425425
cfg.SpanOptions.SpanFilter = spanFilterFn
@@ -552,7 +552,7 @@ func TestOtConn_PrepareContext(t *testing.T) {
552552
ctx, sr, tracer, dummySpan := prepareTraces(tc.noParentSpan)
553553

554554
// New conn
555-
cfg := newMockConfig(t, tracer)
555+
cfg := newMockConfig(t, tracer, nil)
556556
cfg.SpanOptions.DisableQuery = tc.disableQuery
557557
cfg.SpanOptions.OmitConnPrepare = omitConnPrepare
558558
cfg.SpanOptions.SpanFilter = spanFilterFn
@@ -659,7 +659,7 @@ func TestOtConn_BeginTx(t *testing.T) {
659659
ctx, sr, tracer, dummySpan := prepareTraces(tc.noParentSpan)
660660

661661
// New conn
662-
cfg := newMockConfig(t, tracer)
662+
cfg := newMockConfig(t, tracer, nil)
663663
cfg.SpanOptions.SpanFilter = spanFilterFn
664664
cfg.AttributesGetter = tc.attributesGetter
665665

@@ -758,7 +758,7 @@ func TestOtConn_ResetSession(t *testing.T) {
758758
ctx, sr, tracer, dummySpan := prepareTraces(tc.noParentSpan)
759759

760760
// New conn
761-
cfg := newMockConfig(t, tracer)
761+
cfg := newMockConfig(t, tracer, nil)
762762
cfg.SpanOptions.OmitConnResetSession = omitResetSession
763763
cfg.SpanOptions.SpanFilter = spanFilterFn
764764
cfg.AttributesGetter = tc.attributesGetter

connector_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ func TestOtConnector_Connect(t *testing.T) {
106106
// Prepare traces
107107
ctx, sr, tracer, dummySpan := prepareTraces(tc.noParentSpan)
108108

109-
cfg := newMockConfig(t, tracer)
109+
cfg := newMockConfig(t, tracer, nil)
110110
cfg.SpanOptions.OmitConnectorConnect = omitConnectorConnect
111111
cfg.SpanOptions.SpanFilter = spanFilterFn
112112
cfg.AttributesGetter = tc.attributesGetter

instruments.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"strings"
2020

2121
"go.opentelemetry.io/otel/metric"
22+
semconv "go.opentelemetry.io/otel/semconv/v1.30.0"
2223
)
2324

2425
const (
@@ -36,21 +37,32 @@ type dbStatsInstruments struct {
3637
}
3738

3839
type instruments struct {
39-
// The latency of calls in milliseconds
40-
latency metric.Float64Histogram
40+
// The legacyLatency of calls in milliseconds
41+
legacyLatency metric.Float64Histogram
42+
// The duration of calls in seconds
43+
duration metric.Float64Histogram
4144
}
4245

4346
func newInstruments(meter metric.Meter) (*instruments, error) {
4447
var instruments instruments
4548
var err error
4649

47-
if instruments.latency, err = meter.Float64Histogram(
50+
if instruments.legacyLatency, err = meter.Float64Histogram(
4851
strings.Join([]string{namespace, "latency"}, "."),
4952
metric.WithDescription("The latency of calls in milliseconds"),
5053
metric.WithUnit("ms"),
5154
); err != nil {
52-
return nil, fmt.Errorf("failed to create latency instrument, %v", err)
55+
return nil, fmt.Errorf("failed to create legacy latency instrument, %v", err)
5356
}
57+
58+
if instruments.duration, err = meter.Float64Histogram(
59+
semconv.DBClientOperationDurationName,
60+
metric.WithDescription(semconv.DBClientOperationDurationDescription),
61+
metric.WithUnit(semconv.DBClientOperationDurationUnit),
62+
); err != nil {
63+
return nil, fmt.Errorf("failed to create duration instrument, %v", err)
64+
}
65+
5466
return &instruments, nil
5567
}
5668

instruments_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ func TestNewInstruments(t *testing.T) {
2727
require.NoError(t, err)
2828

2929
assert.NotNil(t, instruments)
30-
assert.NotNil(t, instruments.latency)
30+
assert.NotNil(t, instruments.legacyLatency)
31+
assert.NotNil(t, instruments.duration)
3132
}
3233

3334
func TestNewDBStatsInstruments(t *testing.T) {

internal/semconv/attributes.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@
1515
package semconv
1616

1717
import (
18+
"database/sql/driver"
19+
"errors"
20+
"fmt"
21+
"reflect"
22+
1823
"go.opentelemetry.io/otel/attribute"
1924
semconvlegacy "go.opentelemetry.io/otel/semconv/v1.24.0"
2025
semconv "go.opentelemetry.io/otel/semconv/v1.30.0"
@@ -53,3 +58,35 @@ func NewDBQueryTextAttributes(optInType OTelSemConvStabilityOptInType) func(quer
5358
}
5459
}
5560
}
61+
62+
// ErrorTypeAttributes converts an error to a slice of attribute.KeyValue.
63+
func ErrorTypeAttributes(err error) []attribute.KeyValue {
64+
if err == nil {
65+
return nil
66+
}
67+
68+
// Handle common driver errors with specific error types
69+
switch {
70+
case errors.Is(err, driver.ErrBadConn):
71+
return []attribute.KeyValue{semconv.ErrorTypeKey.String("database/sql/driver.ErrBadConn")}
72+
case errors.Is(err, driver.ErrSkip):
73+
return []attribute.KeyValue{semconv.ErrorTypeKey.String("database/sql/driver.ErrSkip")}
74+
case errors.Is(err, driver.ErrRemoveArgument):
75+
return []attribute.KeyValue{semconv.ErrorTypeKey.String("database/sql/driver.ErrRemoveArgument")}
76+
}
77+
78+
t := reflect.TypeOf(err)
79+
var value string
80+
if t.PkgPath() == "" && t.Name() == "" {
81+
// Likely a builtin type.
82+
value = t.String()
83+
} else {
84+
value = fmt.Sprintf("%s.%s", t.PkgPath(), t.Name())
85+
}
86+
87+
if value == "" {
88+
return []attribute.KeyValue{semconv.ErrorTypeOther}
89+
}
90+
91+
return []attribute.KeyValue{semconv.ErrorTypeKey.String(value)}
92+
}

internal/semconv/attributes_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
package semconv
1616

1717
import (
18+
"database/sql/driver"
19+
"fmt"
1820
"testing"
1921

2022
"github.com/stretchr/testify/assert"
@@ -75,3 +77,58 @@ func TestNewDBQueryTextAttributes(t *testing.T) {
7577
})
7678
}
7779
}
80+
81+
// customError is a test error type.
82+
type customError struct {
83+
msg string
84+
}
85+
86+
func (e customError) Error() string {
87+
return e.msg
88+
}
89+
90+
func TestErrorTypeAttributes(t *testing.T) {
91+
tests := []struct {
92+
name string
93+
err error
94+
expected []attribute.KeyValue
95+
}{
96+
{
97+
name: "nil error",
98+
err: nil,
99+
expected: nil,
100+
},
101+
{
102+
name: "driver.ErrBadConn",
103+
err: driver.ErrBadConn,
104+
expected: []attribute.KeyValue{semconv.ErrorTypeKey.String("database/sql/driver.ErrBadConn")},
105+
},
106+
{
107+
name: "driver.ErrSkip",
108+
err: driver.ErrSkip,
109+
expected: []attribute.KeyValue{semconv.ErrorTypeKey.String("database/sql/driver.ErrSkip")},
110+
},
111+
{
112+
name: "driver.ErrRemoveArgument",
113+
err: driver.ErrRemoveArgument,
114+
expected: []attribute.KeyValue{semconv.ErrorTypeKey.String("database/sql/driver.ErrRemoveArgument")},
115+
},
116+
{
117+
name: "custom error type",
118+
err: customError{msg: "test error"},
119+
expected: []attribute.KeyValue{semconv.ErrorTypeKey.String("github.com/XSAM/otelsql/internal/semconv.customError")},
120+
},
121+
{
122+
name: "built-in error",
123+
err: fmt.Errorf("some error"),
124+
expected: []attribute.KeyValue{semconv.ErrorTypeKey.String("*errors.errorString")},
125+
},
126+
}
127+
128+
for _, tt := range tests {
129+
t.Run(tt.name, func(t *testing.T) {
130+
result := ErrorTypeAttributes(tt.err)
131+
assert.Equal(t, tt.expected, result)
132+
})
133+
}
134+
}

rows_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ func TestOtRows_Close(t *testing.T) {
8888
ctx, sr, tracer, _ := prepareTraces(false)
8989

9090
mr := newMockRows(tc.error)
91-
cfg := newMockConfig(t, tracer)
91+
cfg := newMockConfig(t, tracer, nil)
9292
cfg.SpanOptions.SpanFilter = spanFilterFn
9393

9494
// New rows
@@ -159,7 +159,7 @@ func TestOtRows_Next(t *testing.T) {
159159
ctx, sr, tracer, _ := prepareTraces(false)
160160

161161
mr := newMockRows(tc.error)
162-
cfg := newMockConfig(t, tracer)
162+
cfg := newMockConfig(t, tracer, nil)
163163
cfg.SpanOptions.RowsNext = tc.rowsNextOption
164164
cfg.SpanOptions.SpanFilter = spanFilterFn
165165

@@ -243,7 +243,7 @@ func TestNewRows(t *testing.T) {
243243
ctx, sr, tracer, dummySpan := prepareTraces(tc.noParentSpan)
244244

245245
mr := newMockRows(false)
246-
cfg := newMockConfig(t, tracer)
246+
cfg := newMockConfig(t, tracer, nil)
247247
cfg.SpanOptions.OmitRows = omitRows
248248
cfg.SpanOptions.SpanFilter = spanFilterFn
249249
cfg.AttributesGetter = tc.attributesGetter

stmt_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ func TestOtStmt_ExecContext(t *testing.T) {
176176
}
177177

178178
// New stmt
179-
cfg := newMockConfig(t, tracer)
179+
cfg := newMockConfig(t, tracer, nil)
180180
cfg.SpanOptions.DisableQuery = tc.disableQuery
181181
cfg.SpanOptions.SpanFilter = spanFilterFn
182182
cfg.AttributesGetter = tc.attributesGetter
@@ -288,7 +288,7 @@ func TestOtStmt_QueryContext(t *testing.T) {
288288
}
289289

290290
// New stmt
291-
cfg := newMockConfig(t, tracer)
291+
cfg := newMockConfig(t, tracer, nil)
292292
cfg.SpanOptions.DisableQuery = tc.disableQuery
293293
cfg.SpanOptions.SpanFilter = spanFilterFn
294294
cfg.AttributesGetter = tc.attributesGetter
@@ -355,7 +355,7 @@ func TestOtStmt_CheckNamedValue(t *testing.T) {
355355
{
356356
name: "stmt and conn do not implement NamedValueChecker",
357357
stmt: newMockLegacyStmt(false),
358-
otConn: newConn(&mockConn{}, newMockConfig(t, nil)),
358+
otConn: newConn(&mockConn{}, newMockConfig(t, nil, nil)),
359359
err: driver.ErrSkip,
360360
},
361361
{
@@ -379,22 +379,22 @@ func TestOtStmt_CheckNamedValue(t *testing.T) {
379379
otConn: newConn(&struct {
380380
driver.Conn
381381
driver.NamedValueChecker
382-
}{NamedValueChecker: &namedValueChecker{}}, newMockConfig(t, nil)),
382+
}{NamedValueChecker: &namedValueChecker{}}, newMockConfig(t, nil, nil)),
383383
},
384384
{
385385
name: "only conn implements NamedValueChecker, but has error",
386386
stmt: newMockLegacyStmt(false),
387387
otConn: newConn(&struct {
388388
driver.Conn
389389
driver.NamedValueChecker
390-
}{NamedValueChecker: &namedValueChecker{err: assert.AnError}}, newMockConfig(t, nil)),
390+
}{NamedValueChecker: &namedValueChecker{err: assert.AnError}}, newMockConfig(t, nil, nil)),
391391
err: assert.AnError,
392392
},
393393
}
394394

395395
for _, tc := range testCases {
396396
t.Run(tc.name, func(t *testing.T) {
397-
stmt := newStmt(tc.stmt, newMockConfig(t, nil), "", tc.otConn)
397+
stmt := newStmt(tc.stmt, newMockConfig(t, nil, nil), "", tc.otConn)
398398
err := stmt.CheckNamedValue(nil)
399399
assert.Equal(t, tc.err, err)
400400
})

0 commit comments

Comments
 (0)