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
| Need | Use |
|---|---|
| Inspect completed query-builder events, including failures | QueryContext.onQuery / onMutation |
| Log every statement and lifecycle boundary | OrmConnection.listen / beforeExecuting |
| Surround raw SQL, schema, migrations, streams, and transactions | QueryInterceptor |
| Emit database spans | OrmOpenTelemetryInterceptor |
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:
- Notes
- Code
- 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.
Future<void> queryLoggingExample() async {
final dataSource = DataSource(
DataSourceOptions(
name: 'primary',
driver: SqliteDriverAdapter.inMemory(),
entities: generatedOrmModelDefinitions,
logging: true, // Enable query logging
),
);
await dataSource.init();
// Execute queries...
await dataSource.query<$User>().get();
// Review the log
for (final entry in dataSource.queryLog) {
print('SQL: ${entry.sql}');
print('Parameters: ${entry.parameters}');
print('Duration: ${entry.duration}');
}
// Clear when done
dataSource.flushQueryLog();
dataSource.disableQueryLog();
}
API Reference
| Method | Description |
|---|---|
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 |
queryLog | Returns an immutable list of QueryLogEntry objects |
loggingQueries | Returns true when logging is active |
QueryLogEntry Fields
| Field | Description |
|---|---|
type | 'query', 'mutation', or 'transaction' |
sql | The SQL statement with bindings interpolated |
preview | Full StatementPreview with raw SQL and parameter lists |
duration | Execution time |
success | true if no error was thrown |
model | Model name (e.g., 'User') when applicable |
table | Table name (e.g., 'users') when applicable |
rowCount | Rows returned or affected |
error | Exception object when success is false |
parameters | Bind 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
| Field | Description |
|---|---|
plan | QueryPlan executed against the driver |
preview | StatementPreview with SQL text |
duration | Wall-clock Duration for the execution |
rows | Number of rows returned |
error / stackTrace | Populated when the driver threw |
succeeded | true when no error occurred |
MutationEvent Fields
| Field | Description |
|---|---|
plan | MutationPlan (operation, rows, returning flag) |
preview | SQL preview for the mutation |
duration | Execution time |
affectedRows | Driver-reported row count |
error / stackTrace | Failure context |
succeeded | Indicates 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:
- Notes
- Code
- 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.
Future<OrmDatabase> tracingIntegrationExample() async {
await dotel.OTel.initialize(serviceName: 'catalog-api');
return SqliteDatabase.connect(
path: ':memory:',
interceptors: [
OrmOpenTelemetryInterceptor(
includeParameterCount: true,
includeSql: false,
),
QueryTimingInterceptor(),
],
);
}
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
QueryContextis created to capture migrations, seed data, and runtime queries. -
Protect secrets – Disable
includeParametersor scrub entries insideonLogfor columns that may contain PII. -
Correlate with tracing – Prefer
QueryInterceptorfor span lifecycle and keeponQuery/onMutationfor metrics or post-execution diagnostics. -
Monitor slow queries – Register a listener that warns when
event.durationexceeds 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
- Initialize Dartastic once when using
OrmOpenTelemetryInterceptor. - Run one read + one write query, including one operation inside a transaction.
- Confirm spans include the driver and operation, and child operations share the transaction id.
- Confirm SQL and parameters follow your telemetry policy.