Overview
The Query Log system tracks every SQL query executed against DuckDB tables, providing:
- Complete audit trail - Every query with timestamps and user attribution
- Error tracking - Failed queries with error messages
- Performance metrics - Execution times, row counts, and column counts
- Global sequencing - Temporal ordering for replay and rollback
- Per-table history - Isolated query logs for each data table
Query logs use a global sequence number to ensure consistent temporal ordering across all users and tables.
Schema
See the queryLog table definition for complete field reference.
Core Functions
logQuery
Records a query execution with metadata.
The SQL query string executed
DuckDB table name the query was executed against
Whether the query executed successfully
Error message if the query failed
Query result statistics
Number of columns in result set
Query execution duration in milliseconds
The ID of the created query log entry
export const logQuery = mutation({
args: {
query: v.string(),
tableName: v.string(),
success: v.boolean(),
error: v.optional(v.string()),
resultMetadata: v.optional(
v.object({
rowCount: v.optional(v.number()),
columnCount: v.optional(v.number()),
executionTimeMs: v.optional(v.number()),
})
),
},
handler: async (ctx, args) => {
const userId = await checkAuth(ctx)
// Get highest global sequence number
const allQueries = await ctx.db.query('queryLog').collect()
const maxSequence = allQueries.length > 0
? Math.max(...allQueries.map(q => q.sequenceNumber))
: -1
const sequenceNumber = maxSequence + 1
return await ctx.db.insert('queryLog', {
query: args.query,
tableName: args.tableName,
executedAt: Date.now(),
userId,
success: args.success,
error: args.error,
sequenceNumber,
resultMetadata: args.resultMetadata,
})
},
})
Usage Example
import { useMutation } from 'convex/react'
import { api } from '@/convex/_generated/api'
function QueryExecutor() {
const logQuery = useMutation(api.queryLog.logQuery)
const executeQuery = async (sql: string, tableName: string) => {
const startTime = Date.now()
try {
// Execute query against DuckDB
const result = await executeDuckDBQuery(sql)
const executionTime = Date.now() - startTime
// Log successful execution
await logQuery({
query: sql,
tableName,
success: true,
resultMetadata: {
rowCount: result.rows.length,
columnCount: result.columns.length,
executionTimeMs: executionTime,
},
})
return result
} catch (error) {
// Log failed execution
await logQuery({
query: sql,
tableName,
success: false,
error: error.message,
})
throw error
}
}
return (
<button onClick={() => executeQuery('SELECT * FROM sales', 'sales')}>
Run Query
</button>
)
}
getQueryLogs
Retrieve query history for the current user, optionally filtered by table.
Filter logs to a specific table (omit to get all tables)
Maximum number of logs to return
Array of query log records, sorted by sequence number (most recent first)
export const getQueryLogs = query({
args: {
tableName: v.optional(v.string()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const userId = await checkAuth(ctx)
const limit = args.limit || 1000
let dbQuery = ctx.db
.query('queryLog')
.withIndex('by_userId', q => q.eq('userId', userId))
// Filter by tableName if provided
if (args.tableName) {
dbQuery = ctx.db
.query('queryLog')
.withIndex('by_userId_tableName', q =>
q.eq('userId', userId).eq('tableName', args.tableName)
)
}
const logs = await dbQuery.order('desc').take(limit)
return logs.sort((a, b) => b.sequenceNumber - a.sequenceNumber)
},
})
Usage Example
import { useQuery } from 'convex/react'
import { api } from '@/convex/_generated/api'
function QueryHistory({ tableName }: { tableName: string }) {
const logs = useQuery(api.queryLog.getQueryLogs, { tableName, limit: 50 })
if (!logs) return <div>Loading...</div>
return (
<div>
<h2>Query History</h2>
{logs.map(log => (
<div key={log._id}>
<code>{log.query}</code>
<span>{log.success ? '✓' : '✗'}</span>
<span>{new Date(log.executedAt).toLocaleString()}</span>
{log.resultMetadata && (
<span>
{log.resultMetadata.rowCount} rows in {log.resultMetadata.executionTimeMs}ms
</span>
)}
</div>
))}
</div>
)
}
getQueryLogsUpTo
Retrieve all queries up to a specific sequence number (for rollback/replay).
DuckDB table name (safety: only returns logs for this table)
Maximum sequence number to include
Array of query logs sorted by sequence number (ascending)
export const getQueryLogsUpTo = query({
args: {
tableName: v.string(),
sequenceNumber: v.number(),
},
handler: async (ctx, args) => {
const userId = await checkAuth(ctx)
const allLogs = await ctx.db
.query('queryLog')
.withIndex('by_userId_tableName', q =>
q.eq('userId', userId).eq('tableName', args.tableName)
)
.collect()
return allLogs
.filter(log => log.sequenceNumber <= args.sequenceNumber)
.sort((a, b) => a.sequenceNumber - b.sequenceNumber)
},
})
getLatestSequenceNumber
Get the most recent sequence number for a table (or all tables).
Filter to a specific table (omit for global latest)
Latest sequence number, or null if no queries exist
export const getLatestSequenceNumber = query({
args: {
tableName: v.optional(v.string()),
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx)
if (!userId) return null
let dbQuery = ctx.db
.query('queryLog')
.withIndex('by_userId', q => q.eq('userId', userId))
if (args.tableName) {
dbQuery = ctx.db
.query('queryLog')
.withIndex('by_userId_tableName', q =>
q.eq('userId', userId).eq('tableName', args.tableName)
)
}
const allQueries = await dbQuery.collect()
if (allQueries.length === 0) return null
return Math.max(...allQueries.map(q => q.sequenceNumber))
},
})
Replay & Rollback
The query log enables time-travel functionality by replaying queries up to a specific point:
async function replayToSequenceNumber(
tableName: string,
targetSequence: number
) {
// 1. Get all queries up to target sequence
const logs = await ctx.runQuery(api.queryLog.getQueryLogsUpTo, {
tableName,
sequenceNumber: targetSequence,
})
// 2. Reset table to initial state
await executeDuckDBQuery(`DROP TABLE IF EXISTS ${tableName}_replay`)
await executeDuckDBQuery(`CREATE TABLE ${tableName}_replay AS SELECT * FROM ${tableName}_original`)
// 3. Replay successful queries in order
for (const log of logs) {
if (log.success) {
try {
await executeDuckDBQuery(
log.query.replace(tableName, `${tableName}_replay`)
)
} catch (error) {
console.warn(`Failed to replay query ${log.sequenceNumber}:`, error)
}
}
}
// 4. Swap tables
await executeDuckDBQuery(`DROP TABLE ${tableName}`)
await executeDuckDBQuery(`ALTER TABLE ${tableName}_replay RENAME TO ${tableName}`)
}
Replay functionality should be implemented client-side or in a Convex action, since it requires executing DuckDB queries.
Query Log Size
Query logs grow over time. Consider:
- Archiving old logs - Move logs older than 90 days to cold storage
- Per-table limits - Keep only the last N queries per table
- Compression - Store large query strings separately
Index Optimization
The by_userId_tableName composite index is optimized for the most common query pattern:
// Fast: Uses composite index
const logs = await ctx.db
.query('queryLog')
.withIndex('by_userId_tableName', q =>
q.eq('userId', userId).eq('tableName', tableName)
)
.collect()
// Slower: Only uses first part of index
const logs = await ctx.db
.query('queryLog')
.withIndex('by_userId', q => q.eq('userId', userId))
.filter(q => q.eq(q.field('tableName'), tableName))
.collect()
Analytics Use Cases
function analyzeQueryPerformance(logs: QueryLog[]) {
const successfulQueries = logs.filter(log => log.success)
const avgExecutionTime = successfulQueries.reduce(
(sum, log) => sum + (log.resultMetadata?.executionTimeMs || 0),
0
) / successfulQueries.length
const slowQueries = successfulQueries.filter(
log => (log.resultMetadata?.executionTimeMs || 0) > 1000
)
return {
totalQueries: logs.length,
successRate: (successfulQueries.length / logs.length) * 100,
avgExecutionTime,
slowQueries,
}
}
Most Common Queries
function findCommonQueries(logs: QueryLog[]) {
const queryCounts = new Map<string, number>()
logs.forEach(log => {
const normalized = log.query.trim().toLowerCase()
queryCounts.set(normalized, (queryCounts.get(normalized) || 0) + 1)
})
return Array.from(queryCounts.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
}
See Also
Schema Reference
Complete queryLog table definition
Insights Cache
AI-powered query result analysis
DuckDB Integration
Learn about the DuckDB query engine