Observability
The ORM exposes structured hooks for every query and mutation so you can ship metrics, traces, and logs without patching driver code.
Prerequisites
What You’ll Learn
- How to capture and inspect structured query logs
- How to stream ORM events to metrics/tracing systems
- How to debug slow or failing operations with runtime hooks
Step Outcome
By the end of this page, you should be able to:
- Capture query/mutation telemetry without modifying driver code
- Route ORM events to your logging/metrics/tracing stack
- Investigate slow/failing database operations with concrete runtime data
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('Bindings: ${entry.bindings}');
print('Duration: ${entry.duration}');
}
// Clear when done
dataSource.clearQueryLog();
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 |
clearQueryLog() | 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 onQueryLogged to receive entries as they are recorded:
void onQueryLoggedExample(OrmConnection connection) {
connection.onQueryLogged((entry) {
print('SQL ${entry.type}: ${entry.sql}');
});
}
Query & Mutation Events
Register listeners on the QueryContext:
Future<void> queryEventsExample(DataSource dataSource) async {
// Listen for query events
dataSource.onQuery((event) {
print('Query: ${event.sql}');
print('Duration: ${event.duration}ms');
if (event.duration.inMilliseconds > 100) {
print('SLOW QUERY: ${event.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(QueryLogEntry entry) {
final logEntry = {
'sql': entry.sql,
'bindings': entry.bindings,
'duration_ms': entry.duration.inMilliseconds,
'timestamp': DateTime.now().toIso8601String(),
};
print(logEntry);
}
}
Future<void> structuredLoggerExample(DataSource dataSource) async {
final logger = StructuredQueryLogger();
dataSource.onQuery((event) {
logger.log(event);
});
}
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 {
// Log before queries execute
dataSource.beforeQuery((sql, bindings) {
print('About to execute: $sql');
print('With parameters: $bindings');
});
// Track query counts
var queryCount = 0;
dataSource.beforeQuery((sql, bindings) {
queryCount++;
});
await dataSource.query<$User>().get();
print('Total queries: $queryCount');
}
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 Tracing
Use event hooks to create tracing spans:
- Notes
- Code
- Use
event.preview.sql/event.durationto label spans consistently. - Prefer a low-cardinality span name (e.g.
db.query) and put SQL in attributes.
void tracingIntegrationExample(QueryContext context, Tracer tracer) {
context.onQuery((event) {
final span = tracer.startSpan('db.query')
..setAttribute('db.statement', event.preview.sql)
..setAttribute('db.system', 'sqlite');
if (event.succeeded) {
span.end();
} else {
span.recordException(event.error!, event.stackTrace);
span.setStatus(SpanStatus.error);
span.end();
}
});
}
// Placeholder tracer interface
abstract class Tracer {
Span startSpan(String name);
}
abstract class Span {
void setAttribute(String key, String value);
void recordException(Object error, StackTrace? stackTrace);
void setStatus(SpanStatus status);
void end();
}
enum SpanStatus { ok, error }
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 – Use
onQuery/onMutationto start tracing spans using the SQL preview and duration data. -
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
- Enable query logging and run one read + one write query.
- Confirm log entries include SQL + duration + success/failure.
- Trigger a known failure and confirm error fields are emitted.