Skip to main content

Observability

Ormed exposes two complementary observability layers: passive query/event listeners for logs and diagnostics, and ordered execution interceptors for middleware that must surround the operation. The core package also includes a Dartastic OpenTelemetry interceptor, so tracing does not require a second Ormed integration package.

Choose the hook

NeedUse
Inspect completed query-builder events, including failuresQueryContext.onQuery / onMutation
Log every statement and lifecycle boundaryOrmConnection.listen / beforeExecuting
Surround raw SQL, schema, migrations, streams, and transactionsQueryInterceptor
Emit database spansOrmOpenTelemetryInterceptor

Listeners observe work after it is dispatched. Interceptors are the boundary to use when the same policy must cover generated queries and direct facade calls.

OpenTelemetry with Dartastic

Initialize Dartastic once during application startup, then install the built-in interceptor on the database connection:

Future<OrmDatabase> openDatabaseWithTracing() async {
await dotel.OTel.initialize(serviceName: 'catalog-api');

return SqliteDatabase.connect(
path: ':memory:',
interceptors: [OrmOpenTelemetryInterceptor()],
);
}

OrmOpenTelemetryInterceptor creates client spans with operation, driver, connection, database, collection, parameter-count, and transaction metadata. SQL text is deliberately excluded by default. Enable it only when the deployment’s telemetry policy permits it:

final db = await SqliteDatabase.connect(
path: 'database/app.sqlite',
interceptors: [
OrmOpenTelemetryInterceptor(includeSql: true),
],
);

The interceptor covers query-builder reads and writes, queryRaw/ executeRaw, schema plans, migrations, streams, and managed transactions. Operations inside a transaction carry the same ormed.transaction.id, making the database work easy to correlate with the parent span.

Custom execution middleware

Implement QueryInterceptor when you need request-local authorization, timing, redaction, sampling, or a custom tracing backend:

class QueryTimingInterceptor extends QueryInterceptor {

Future<T> intercept<T>(
QueryExecutionContext context,
Future<T> Function() next,
) async {
final startedAt = DateTime.now();
try {
return await next();
} finally {
final elapsed = DateTime.now().difference(startedAt);
print('${context.operationName} took ${elapsed.inMilliseconds}ms');
}
}
}

Register interceptors in the order they should wrap one another. An interceptor can reject an operation by throwing or short-circuit it by returning without calling next. Use interceptStream when a streaming query needs stream-level transformation rather than only setup/teardown around the stream.

Query Logging

The simplest way to observe queries is via the built-in query log on OrmConnection or DataSource:

  • Use this for development or simple production diagnostics.
  • Disable parameter capture for sensitive fields by setting includeParameters: false.
  • When enabled, transaction boundaries (BEGIN/COMMIT/ROLLBACK/SAVEPOINT) are also logged as type: transaction.

API Reference

MethodDescription
enableQueryLog({includeParameters, clear})Enables logging; includeParameters controls whether bind values are captured (default: true); clear resets the log (default: true)
disableQueryLog({clear})Disables logging; optionally clears entries
flushQueryLog()Removes all accumulated entries
queryLogReturns an immutable list of QueryLogEntry objects
loggingQueriesReturns true when logging is active

QueryLogEntry Fields

FieldDescription
type'query', 'mutation', or 'transaction'
sqlThe SQL statement with bindings interpolated
previewFull StatementPreview with raw SQL and parameter lists
durationExecution time
successtrue if no error was thrown
modelModel name (e.g., 'User') when applicable
tableTable name (e.g., 'users') when applicable
rowCountRows returned or affected
errorException object when success is false
parametersBind values (empty when includeParameters is false)
toMap()JSON-serializable representation

Start with query logs first, then add event hooks and tracing once baseline visibility is in place.

Listening to Log Events

Use listen to receive completed statements as they are recorded:

void onQueryLoggedExample(OrmConnection connection) {
connection.listen((entry) {
print('SQL: ${entry.sql} (${entry.time}ms)');
});
}

Query & Mutation Events

Register listeners on the QueryContext:

Future<void> queryEventsExample(DataSource dataSource) async {
// Listen for query events
dataSource.context.onQuery((event) {
print('Query: ${event.preview.sql}');
print('Duration: ${event.duration}ms');

if (event.duration.inMilliseconds > 100) {
print('SLOW QUERY: ${event.preview.sql}');
}
});

// Execute queries
await dataSource.query<$User>().get();
}

QueryEvent Fields

FieldDescription
planQueryPlan executed against the driver
previewStatementPreview with SQL text
durationWall-clock Duration for the execution
rowsNumber of rows returned
error / stackTracePopulated when the driver threw
succeededtrue when no error occurred

MutationEvent Fields

FieldDescription
planMutationPlan (operation, rows, returning flag)
previewSQL preview for the mutation
durationExecution time
affectedRowsDriver-reported row count
error / stackTraceFailure context
succeededIndicates success

StructuredQueryLogger

Attach a structured logger for JSON-friendly output:

class StructuredQueryLogger {
void log(QueryExecuted entry) {
final logEntry = {
'sql': entry.sql,
'bindings': entry.bindings,
'duration_ms': entry.time,
'timestamp': DateTime.now().toIso8601String(),
};
print(logEntry);
}
}

Future<void> structuredLoggerExample(DataSource dataSource) async {
final logger = StructuredQueryLogger();

dataSource.listen(logger.log);
}

Each entry contains:

{
"type": "query",
"timestamp": "2025-01-01T12:00:00.000Z",
"model": "User",
"table": "users",
"sql": "SELECT \"id\" FROM \"users\" WHERE \"email\" = ?",
"parameters": ["alice@example.com"],
"duration_ms": 1.23,
"success": true,
"env": "prod"
}

For failed queries, the logger adds error_type, error_message, and (optionally) stack_trace.

Printing Helper

For development or when piping logs to stdout:

void printingHelperExample(QueryContext context) {
// For development or when piping logs to stdout:
// StructuredQueryLogger.printing(pretty: true).attach(context);
}

SQL Preview Without Execution

Use Query.toSql() to inspect queries before running them:

Future<void> sqlPreviewExample(DataSource dataSource) async {
// Preview SQL without executing
final query = dataSource
.query<$User>()
.whereEquals('active', true)
.orderBy('name');

final sql = query.toSql();
print('Generated SQL: $sql');

// Then execute if needed
final users = await query.get();
}

Mutation previews are available via repository helpers (previewInsert, previewUpdateMany, etc.) and context.describeMutation().

Connection Instrumentation

OrmConnection provides additional hooks for fine-grained control.

Before Hooks

Intercept queries or mutations before they execute:

Future<void> beforeHooksExample(DataSource dataSource) async {
final unregister = dataSource.beforeExecuting((statement) {
print('About to execute: ${statement.sql}');
print('With parameters: ${statement.parameters}');
});

await dataSource.query<$User>().get();
unregister();
}

beforeExecuting

Called just before SQL is sent to the driver, with full statement context:

void beforeExecutingExample(OrmConnection connection) {
final unregister = connection.beforeExecuting((statement) {
print('[SQL] ${statement.sqlWithBindings}');
print('Type: ${statement.type}'); // query or mutation
print('Connection: ${statement.connectionName}');
});

// Later, unregister
unregister();
}

Slow Query Detection

void slowQueryDetectionExample(OrmConnection connection) {
connection.whenQueryingForLongerThan(Duration(milliseconds: 100), (event) {
print('Slow query: ${event.statement.sql}');
print('Duration: ${event.duration.inMilliseconds}ms');
});
}

Pretend Mode

Capture SQL without executing against the database:

Future<void> pretendModeExample(DataSource ds, User user, Post post) async {
final captured = await ds.pretend(() async {
await ds.repo<$User>().insert(user);
await ds.repo<$Post>().insert(post);
});

for (final entry in captured) {
print('Would execute: ${entry.sql}');
}
// No actual database changes made
}

During pretend mode, connection.pretending returns true.

Integration with another tracing system

If your application already owns a tracing adapter, use an execution interceptor so raw SQL and transaction boundaries are covered as well:

  • Prefer a low-cardinality span name (for example db.query) and put SQL in attributes only when the data is safe to export.
  • Keep the interceptor responsible for span lifecycle; keep passive event listeners for metrics and diagnostic logs.

Metrics Integration

Send query metrics to your monitoring service:

void metricsIntegrationExample(QueryContext context, Metrics metrics) {
context.onQuery((event) {
metrics.histogram('db.query.duration', event.duration.inMicroseconds);
metrics.increment('db.query.count');

if (!event.succeeded) {
metrics.increment('db.query.errors');
}
});

context.onMutation((event) {
metrics.histogram('db.mutation.duration', event.duration.inMicroseconds);
metrics.increment('db.mutation.affected_rows', event.affectedRows);
});
}

// Placeholder metrics interface
abstract class Metrics {
void histogram(String name, int value);
void increment(String name, [int value = 1]);
}

Best Practices

  • Attach early – Add event listeners as soon as the QueryContext is created to capture migrations, seed data, and runtime queries.

  • Protect secrets – Disable includeParameters or scrub entries inside onLog for columns that may contain PII.

  • Correlate with tracing – Prefer QueryInterceptor for span lifecycle and keep onQuery/onMutation for metrics or post-execution diagnostics.

  • Monitor slow queries – Register a listener that warns when event.duration exceeds your SLO.

void slowQueryMonitoringExample(QueryContext context) {
context.onQuery((event) {
if (event.duration > Duration(milliseconds: 100)) {
print('Slow query detected: ${event.preview.sql}');
print('Duration: ${event.duration.inMilliseconds}ms');
}
});
}

Verify Observability Setup

  1. Initialize Dartastic once when using OrmOpenTelemetryInterceptor.
  2. Run one read + one write query, including one operation inside a transaction.
  3. Confirm spans include the driver and operation, and child operations share the transaction id.
  4. Confirm SQL and parameters follow your telemetry policy.

Read This Next