Telemetry (Tracing and Metrics)

ldbc supports distributed tracing and metrics collection compliant with OpenTelemetry Semantic Conventions. Internally it uses otel4s, and works on all platforms: JVM, JS, and Native.

When no OpenTelemetry backend is connected, tracing and metrics automatically become no-ops with zero performance impact.

Dependencies

ldbc-connector depends only on otel4s-core (the abstract API). To actually collect and export telemetry data, you need to add a backend implementation.

libraryDependencies ++= Seq(
  // ldbc connector (includes otel4s-core)
  "io.github.takapi327" %% "ldbc-connector" % "0.6.0",

  // OpenTelemetry Java SDK backend
  "org.typelevel"    %% "otel4s-oteljava"                           % "0.15.1",
  "io.opentelemetry"  % "opentelemetry-exporter-otlp"               % "1.59.0" % Runtime,
  "io.opentelemetry"  % "opentelemetry-sdk-extension-autoconfigure" % "1.59.0" % Runtime,
)

Setup

Set Tracer[F] and Meter[F] on MySQLDataSource.

import cats.effect.*
import io.opentelemetry.api.GlobalOpenTelemetry
import org.typelevel.otel4s.oteljava.OtelJava
import ldbc.connector.*

object Main extends IOApp.Simple:

  override def run: IO[Unit] =
    val resource = for
      otel   <- Resource
                   .eval(IO.delay(GlobalOpenTelemetry.get))
                   .evalMap(OtelJava.forAsync[IO])
      tracer <- Resource.eval(otel.tracerProvider.get("my-app"))
      meter  <- Resource.eval(otel.meterProvider.get("my-app"))
      datasource = MySQLDataSource
                     .build[IO]("127.0.0.1", 3306, "user")
                     .setPassword("password")
                     .setDatabase("mydb")
                     .setTracer(tracer)
                     .setMeter(meter)
      connection <- datasource.getConnection
    yield connection

    resource.use { conn =>
      conn.createStatement().flatMap(_.executeQuery("SELECT 1")).void
    }

When using connection pooling, pass meter to the pooling method.

val pool = MySQLDataSource.pooling[IO](
  config = MySQLConfig.default
    .setHost("127.0.0.1")
    .setPort(3306)
    .setUser("user")
    .setPassword("password")
    .setDatabase("mydb"),
  meter  = Some(meter),
  tracer = Some(tracer)
)

Tracing

Overview

ldbc generates an OpenTelemetry span for each database operation. Spans are created through the exchange mechanism, which works in conjunction with a Mutex to guarantee serialization of MySQL protocol operations.

Span Names

Span names are generated following the OpenTelemetry Semantic Conventions priority:

  1. db.query.summary if available (e.g., SELECT users)
  2. {db.operation.name} {target} (e.g., SELECT users)
  3. {target} only
  4. Fallback: mysql

When extractMetadataFromQueryText in TelemetryConfig is true (default), operation names and table names are automatically extracted from SQL to generate dynamic span names. When false, fixed span names like Execute Statement are used.

The following is a list of the main spans generated by ldbc:

Span Name Description
{operation} {table} Dynamically generated from SQL (e.g., SELECT users)
Execute Statement Statement execution in fixed span name mode
Execute Prepared Statement PreparedStatement execution in fixed span name mode
Execute Statement Batch Statement batch execution
Execute Prepared Statement Batch PreparedStatement batch execution
Callable Statement CallableStatement execution
Create Connection Connection establishment
Close Connection Connection close
Deallocate Prepared Statement Server-side PreparedStatement deallocation
Commit Transaction commit
Rollback Transaction rollback

Span Attributes

Each span is annotated with the following attributes:

Attribute Requirement Level Description Example
db.system.name Required DBMS name mysql
server.address Recommended Hostname 127.0.0.1
server.port Conditionally Required Port number 3306
db.namespace Conditionally Required Database name mydb
db.query.text Opt-In Executed SQL SELECT * FROM users WHERE id = ?
db.collection.name Conditionally Required Table name users
db.operation.name Conditionally Required Operation name SELECT
db.operation.batch.size Conditionally Required Batch size (2 or more) 10
db.mysql.thread_id MySQL thread ID 42
db.mysql.version MySQL server version 8.0.32
error.type Conditionally Required Error type (on failure only) SQLException

Error Recording

When a database operation fails, the following is recorded on the span:

TelemetryConfig

Use TelemetryConfig to customize telemetry behavior.

import ldbc.connector.telemetry.TelemetryConfig

// Default settings (all enabled)
val default = TelemetryConfig.default

// Disable metadata extraction from SQL (use fixed span names)
val fixed = TelemetryConfig.withoutQueryTextExtraction

// Individual settings
val custom = TelemetryConfig.default
  .withoutQueryTextExtraction      // Use fixed span names
  .withoutSanitization             // Disable query text sanitization
  .withoutInClauseCollapsing       // Disable IN clause collapsing
Setting Default Description
extractMetadataFromQueryText true Extract operation name and table name from SQL to dynamically generate span names
sanitizeNonParameterizedQueries true Replace literal values in non-parameterized queries with ?
collapseInClauses true Collapse IN (?, ?, ?) to IN (?)

Query Sanitization

When sanitizeNonParameterizedQueries is enabled, string literals, numbers, NULL values, etc. in the SQL recorded in the db.query.text attribute are replaced with ?. This prevents sensitive data from being included in traces.

-- Original SQL
SELECT * FROM users WHERE name = 'Alice' AND age > 25

-- After sanitization (value recorded in db.query.text)
SELECT * FROM users WHERE name = ? AND age > ?

IN Clause Collapsing

When collapseInClauses is enabled, span cardinality is kept low even when the number of parameters in IN clauses varies.

-- Original SQL
SELECT * FROM users WHERE id IN (?, ?, ?, ?)

-- After collapsing
SELECT * FROM users WHERE id IN (?)

Metrics

Overview

ldbc collects metrics compliant with the OpenTelemetry Database Metrics Semantic Conventions. Metrics are divided into two categories: operation metrics and connection pool metrics.

Operation Metrics

Automatically recorded for all Statement, PreparedStatement, and CallableStatement executions.

db.client.operation.duration

Item Details
Instrument Type Histogram
Unit s (seconds)
Stability Stable
Description Duration of database client operations
Bucket Boundaries [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 5, 10]

Recorded for all of executeQuery, executeUpdate, and executeBatch. Cancelled operations are not recorded.

db.client.response.returned_rows

Item Details
Instrument Type Histogram
Unit {row}
Stability Development
Description Number of rows returned by the query
Bucket Boundaries [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000]

Recorded only for executeQuery-type methods. Not recorded for executeUpdate or executeBatch.

Limitation with Cursor Fetch

When useCursorFetch=true, a StreamingResultSet is used and rows are fetched incrementally in units of fetchSize. In this case, db.client.response.returned_rows records only the number of rows from the initial fetch. Rows fetched subsequently via next() calls are not included.

This is because the OpenTelemetry Semantic Conventions do not provide clear guidance for streaming/cursor-based ResultSets. Neither MySQL Connector/J nor the official OpenTelemetry JDBC instrumentation implement db.client.response.returned_rows at all, making this an industry-wide unresolved issue.

When useCursorFetch=false (the default), all rows are fetched at once and an accurate value is recorded.

Metrics Attributes

Operation metrics are annotated with the following low-cardinality attributes. Unlike trace attributes, high-cardinality attributes such as db.query.text and db.mysql.thread_id are excluded to keep metrics aggregation costs manageable.

Attribute Description Example
db.system.name DBMS name mysql
server.address Hostname 127.0.0.1
server.port Port number 3306
db.namespace Database name mydb

Connection Pool Metrics

Recorded only when using connection pooling (PooledDataSource). All metrics carry the db.client.connection.pool.name attribute.

Histogram Metrics

Metric Name Unit Description
db.client.connection.create_time s Time taken to create a new physical connection
db.client.connection.wait_time s Time waiting to acquire a connection from the pool
db.client.connection.use_time s Time between borrowing a connection and returning it

Counter Metrics

Metric Name Unit Description
db.client.connection.timeouts {timeout} Number of connection acquisition timeouts

Gauge Metrics (Observable)

The following metrics are reported at export time via OTel's BatchCallback.

Metric Name Unit Description
db.client.connection.count {connection} Current number of connections (state attribute: idle/used)
db.client.connection.idle.max {connection} Maximum allowed number of idle connections
db.client.connection.idle.min {connection} Minimum number of idle connections maintained
db.client.connection.max {connection} Maximum allowed number of connections
db.client.connection.pending_requests {request} Number of requests waiting for a connection

Differences Between Traces and Metrics

ldbc uses different attributes for traces and metrics. This follows OpenTelemetry best practices to properly manage metrics cardinality.

Attribute Traces Metrics Reason
db.system.name Low cardinality
server.address Low cardinality
server.port Low cardinality
db.namespace Low cardinality
db.query.text High cardinality, differs per query
db.collection.name Sufficient as a span attribute
db.mysql.thread_id Varies per connection
db.mysql.version Version info is sufficient in spans
error.type Error details are tracked in spans

Selective Use of Tracer/Meter

Tracer and Meter can be used independently.

// Tracing only (no metrics)
val ds = MySQLDataSource.build[IO]("localhost", 3306, "user")
  .setTracer(tracer)

// Metrics only (no tracing)
val ds = MySQLDataSource.build[IO]("localhost", 3306, "user")
  .setMeter(meter)

// Both
val ds = MySQLDataSource.build[IO]("localhost", 3306, "user")
  .setTracer(tracer)
  .setMeter(meter)

// Neither (no-op, zero performance impact)
val ds = MySQLDataSource.build[IO]("localhost", 3306, "user")