feat: add table activation tracking (#39090)

Add telemetry tracking for activation-related table operations
- Implement SQL event parser to detect table creation, data insertion, and RLS enablement
- Add telemetry tracking for these operations in table editor as well
- Add test coverage for SQL event parser
This commit is contained in:
Sean Oliver authored and GitHub committed 2025-10-01 14:54:51 -07:00
1 parent 9ba9c08ef4
commit e978a084b2
8 files changed
+817

No files matched your search

@@ -197,6 +197,19 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp
schema: table.schema,
payload: payload,
})
sendEvent({
action: 'table_rls_enabled',
properties: {
method: 'table_editor',
schema_name: table.schema,
table_name: table.name,
},
groups: {
project: projectRef,
...(org?.slug && { organization: org.slug }),
},
})
}
return (
@@ -21,6 +21,7 @@ import { useTableRowUpdateMutation } from 'data/table-rows/table-row-update-muta
import { tableKeys } from 'data/tables/keys'
import { RetrieveTableResult } from 'data/tables/table-retrieve-query'
import { getTables } from 'data/tables/tables-query'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject'
import { useUrlState } from 'hooks/ui/useUrlState'
import { useGetImpersonatedRoleState } from 'state/role-impersonation-state'
@@ -74,6 +75,7 @@ const SidePanelEditor = ({
const queryClient = useQueryClient()
const { data: project } = useSelectedProjectQuery()
const { data: org } = useSelectedOrganizationQuery()
const [isEdited, setIsEdited] = useState<boolean>(false)
const [isClosingPanel, setIsClosingPanel] = useState<boolean>(false)
@@ -454,6 +456,7 @@ const SidePanelEditor = ({
foreignKeyRelations,
isRLSEnabled,
importContent,
organizationSlug: org?.slug,
})
if (isRealtimeEnabled) await updateTableRealtime(table, true)
@@ -477,6 +480,7 @@ const SidePanelEditor = ({
foreignKeyRelations,
existingForeignKeyRelations,
primaryKey,
organizationSlug: org?.slug,
})
if (table === undefined) {
@@ -32,6 +32,7 @@ import {
updateTable as updateTableMutation,
} from 'data/tables/table-update-mutation'
import { getTables } from 'data/tables/tables-query'
import { sendEvent } from 'data/telemetry/send-event-mutation'
import { timeout, tryParseJson } from 'lib/helpers'
import {
generateCreateColumnPayload,
@@ -461,6 +462,7 @@ export const createTable = async ({
foreignKeyRelations,
isRLSEnabled,
importContent,
organizationSlug,
}: {
projectRef: string
connectionString?: string | null
@@ -474,6 +476,7 @@ export const createTable = async ({
foreignKeyRelations: ForeignKey[]
isRLSEnabled: boolean
importContent?: ImportContent
organizationSlug?: string
}) => {
const queryClient = getQueryClient()
@@ -484,6 +487,26 @@ export const createTable = async ({
payload: payload,
})
// Track table creation event
try {
await sendEvent({
event: {
action: 'table_created',
properties: {
method: 'table_editor',
schema_name: payload.schema,
table_name: payload.name,
},
groups: {
project: projectRef,
...(organizationSlug && { organization: organizationSlug }),
},
},
})
} catch (error) {
console.error('Failed to track table creation event:', error)
}
const table = await queryClient.fetchQuery({
queryKey: tableKeys.retrieve(projectRef, payload.name, payload.schema),
queryFn: ({ signal }) =>
@@ -508,6 +531,26 @@ export const createTable = async ({
schema: table.schema,
payload: { rls_enabled: isRLSEnabled },
})
// Track RLS enablement event
try {
await sendEvent({
event: {
action: 'table_rls_enabled',
properties: {
method: 'table_editor',
schema_name: table.schema,
table_name: table.name,
},
groups: {
project: projectRef,
...(organizationSlug && { organization: organizationSlug }),
},
},
})
} catch (error) {
console.error('Failed to track RLS enablement event:', error)
}
}
// Then insert the columns - we don't do Promise.all as we want to keep the integrity
@@ -662,6 +705,7 @@ export const updateTable = async ({
foreignKeyRelations,
existingForeignKeyRelations,
primaryKey,
organizationSlug,
}: {
projectRef: string
connectionString?: string | null
@@ -672,6 +716,7 @@ export const updateTable = async ({
foreignKeyRelations: ForeignKey[]
existingForeignKeyRelations: ForeignKeyConstraint[]
primaryKey?: Constraint
organizationSlug?: string
}) => {
const queryClient = getQueryClient()
@@ -703,6 +748,28 @@ export const updateTable = async ({
payload,
})
// Track RLS enablement if it's being turned on
if (payload.rls_enabled === true) {
try {
await sendEvent({
event: {
action: 'table_rls_enabled',
properties: {
method: 'table_editor',
schema_name: table.schema,
table_name: payload.name ?? table.name,
},
groups: {
project: projectRef,
...(organizationSlug && { organization: organizationSlug }),
},
},
})
} catch (error) {
console.error('Failed to track RLS enablement event:', error)
}
}
const updatedTable = await queryClient.fetchQuery({
queryKey: tableKeys.retrieve(
projectRef,
@@ -1,6 +1,9 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { sqlEventParser } from 'lib/sql-event-parser'
import { executeSql, ExecuteSqlData, ExecuteSqlVariables } from './execute-sql-query'
// [Joshen] Intention is that we invalidate all database related keys whenever running a mutation related query
@@ -31,12 +34,38 @@ export const useExecuteSqlMutation = ({
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
const { mutate: sendEvent } = useSendEventMutation()
const { data: org } = useSelectedOrganizationQuery()
return useMutation<ExecuteSqlData, QueryResponseError, ExecuteSqlVariables>(
(args) => executeSql(args),
{
async onSuccess(data, variables, context) {
const { contextualInvalidation, sql, projectRef } = variables
// Track all table-related events from SQL execution
try {
const tableEvents = sqlEventParser.getTableEvents(sql)
tableEvents.forEach((event) => {
if (projectRef) {
sendEvent({
action: event.type,
properties: {
method: 'sql_editor',
schema_name: event.schema,
table_name: event.tableName,
},
groups: {
project: projectRef,
...(org?.slug && { organization: org.slug }),
},
})
}
})
} catch (error) {
console.error('Failed to parse SQL for telemetry:', error)
}
// [Joshen] Default to false for now, only used for SQL editor to dynamically invalidate
const sqlLower = sql.toLowerCase()
const isMutationSQL =
@@ -3,6 +3,8 @@ import { toast } from 'sonner'
import { Query } from '@supabase/pg-meta/src/query'
import { executeSql } from 'data/sql/execute-sql-query'
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization'
import { RoleImpersonationState, wrapWithRoleImpersonation } from 'lib/role-impersonation'
import { isRoleImpersonationEnabled } from 'state/role-impersonation-state'
import type { ResponseError } from 'types'
@@ -65,12 +67,33 @@ export const useTableRowCreateMutation = ({
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
const { mutate: sendEvent } = useSendEventMutation()
const { data: org } = useSelectedOrganizationQuery()
return useMutation<TableRowCreateData, ResponseError, TableRowCreateVariables>(
(vars) => createTableRow(vars),
{
async onSuccess(data, variables, context) {
const { projectRef, table } = variables
// Track data insertion event
try {
sendEvent({
action: 'table_data_added',
properties: {
method: 'table_editor',
schema_name: table.schema,
table_name: table.name,
},
groups: {
project: projectRef,
...(org?.slug && { organization: org.slug }),
},
})
} catch (error) {
console.error('Failed to track table data insertion event:', error)
}
await queryClient.invalidateQueries(tableRowKeys.tableRowsAndCount(projectRef, table.id))
await onSuccess?.(data, variables, context)
},
+462
View File
@@ -0,0 +1,462 @@
import { TABLE_EVENT_ACTIONS } from 'common/telemetry-constants'
import { describe, expect, it } from 'vitest'
import { sqlEventParser } from './sql-event-parser'
describe('SQL Event Parser', () => {
describe('CREATE TABLE detection', () => {
it('detects basic CREATE TABLE', () => {
const results = sqlEventParser.getTableEvents('CREATE TABLE users (id INT PRIMARY KEY)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'users',
})
})
it('detects CREATE TABLE with schema', () => {
const results = sqlEventParser.getTableEvents('CREATE TABLE public.users (id INT)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: 'public',
tableName: 'users',
})
})
it('detects CREATE TABLE IF NOT EXISTS', () => {
const results = sqlEventParser.getTableEvents('CREATE TABLE IF NOT EXISTS users (id INT)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'users',
})
})
it('handles quoted identifiers', () => {
const results = sqlEventParser.getTableEvents('CREATE TABLE "public"."user_table" (id INT)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: 'public',
tableName: 'user_table',
})
})
it('returns empty array for non-matching SQL', () => {
const results = sqlEventParser.getTableEvents('SELECT * FROM users')
expect(results).toHaveLength(0)
})
it('detects CREATE TEMPORARY TABLE', () => {
const results = sqlEventParser.getTableEvents('CREATE TEMPORARY TABLE temp_users (id INT)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'temp_users',
})
})
it('detects CREATE TEMP TABLE', () => {
const results = sqlEventParser.getTableEvents('CREATE TEMP TABLE temp_users (id INT)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'temp_users',
})
})
it('detects CREATE UNLOGGED TABLE', () => {
const results = sqlEventParser.getTableEvents('CREATE UNLOGGED TABLE fast_table (id INT)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'fast_table',
})
})
it('detects CREATE TEMP TABLE IF NOT EXISTS', () => {
const results = sqlEventParser.getTableEvents(
'CREATE TEMP TABLE IF NOT EXISTS temp_users (id INT)'
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'temp_users',
})
})
})
describe('INSERT detection', () => {
it('detects basic INSERT INTO', () => {
const results = sqlEventParser.getTableEvents("INSERT INTO users (name) VALUES ('John')")
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableDataAdded,
schema: undefined,
tableName: 'users',
})
})
it('detects INSERT with schema', () => {
const results = sqlEventParser.getTableEvents(
"INSERT INTO public.users (name) VALUES ('John')"
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableDataAdded,
schema: 'public',
tableName: 'users',
})
})
it('handles quoted identifiers', () => {
const results = sqlEventParser.getTableEvents('INSERT INTO "auth"."users" (id) VALUES (1)')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableDataAdded,
schema: 'auth',
tableName: 'users',
})
})
it('returns empty array for non-matching SQL', () => {
const results = sqlEventParser.getTableEvents('UPDATE users SET name = "John"')
expect(results).toHaveLength(0)
})
})
describe('COPY detection', () => {
it('detects basic COPY FROM', () => {
const results = sqlEventParser.getTableEvents("COPY users FROM '/tmp/users.csv'")
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableDataAdded,
schema: undefined,
tableName: 'users',
})
})
it('detects COPY with schema', () => {
const results = sqlEventParser.getTableEvents(
"COPY public.users FROM '/tmp/users.csv' WITH CSV HEADER"
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableDataAdded,
schema: 'public',
tableName: 'users',
})
})
it('handles quoted identifiers', () => {
const results = sqlEventParser.getTableEvents('COPY "auth"."users" FROM STDIN')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableDataAdded,
schema: 'auth',
tableName: 'users',
})
})
it('returns empty array for COPY TO', () => {
const results = sqlEventParser.getTableEvents("COPY users TO '/tmp/users.csv'")
expect(results).toHaveLength(0)
})
it('returns empty array for non-matching SQL', () => {
const results = sqlEventParser.getTableEvents('SELECT * FROM users')
expect(results).toHaveLength(0)
})
})
describe('SELECT INTO detection', () => {
it('detects SELECT INTO', () => {
const results = sqlEventParser.getTableEvents('SELECT * INTO new_users FROM users')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'new_users',
})
})
it('detects SELECT INTO with schema', () => {
const results = sqlEventParser.getTableEvents(
'SELECT id, name INTO public.new_users FROM users'
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: 'public',
tableName: 'new_users',
})
})
it('detects CREATE TABLE AS SELECT', () => {
const results = sqlEventParser.getTableEvents('CREATE TABLE new_users AS SELECT * FROM users')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'new_users',
})
})
it('detects CREATE TABLE IF NOT EXISTS AS SELECT', () => {
const results = sqlEventParser.getTableEvents(
'CREATE TABLE IF NOT EXISTS new_users AS SELECT * FROM users WHERE active = true'
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'new_users',
})
})
it('handles quoted identifiers', () => {
const results = sqlEventParser.getTableEvents(
'SELECT * INTO "backup"."users_2024" FROM users'
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: 'backup',
tableName: 'users_2024',
})
})
it('returns empty array for regular SELECT', () => {
const results = sqlEventParser.getTableEvents('SELECT * FROM users')
expect(results).toHaveLength(0)
})
})
describe('RLS detection', () => {
it('detects ALTER TABLE ENABLE ROW LEVEL SECURITY', () => {
const results = sqlEventParser.getTableEvents('ALTER TABLE users ENABLE ROW LEVEL SECURITY')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
schema: undefined,
tableName: 'users',
})
})
it('detects short form ENABLE RLS', () => {
const results = sqlEventParser.getTableEvents('ALTER TABLE users ENABLE RLS')
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
schema: undefined,
tableName: 'users',
})
})
it('detects with schema', () => {
const results = sqlEventParser.getTableEvents(
'ALTER TABLE public.users ENABLE ROW LEVEL SECURITY'
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
schema: 'public',
tableName: 'users',
})
})
it('handles other ALTER TABLE statements in between', () => {
const results = sqlEventParser.getTableEvents(
'ALTER TABLE users ADD COLUMN test INT, ENABLE ROW LEVEL SECURITY'
)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
schema: undefined,
tableName: 'users',
})
})
it('returns empty array for disabling RLS', () => {
const results = sqlEventParser.getTableEvents('ALTER TABLE users DISABLE ROW LEVEL SECURITY')
expect(results).toHaveLength(0)
})
})
describe('ReDoS protection', () => {
it('handles extremely long identifier names efficiently', () => {
const longIdentifier = 'a'.repeat(10000)
const sql = `CREATE TABLE ${longIdentifier} (id INT)`
const startTime = Date.now()
const results = sqlEventParser.getTableEvents(sql)
const duration = Date.now() - startTime
expect(duration).toBeLessThan(100)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: longIdentifier,
})
})
it('handles nested dots in schema names without catastrophic backtracking', () => {
const maliciousInput = 'a.'.repeat(1000) + 'table'
const sql = `CREATE TABLE ${maliciousInput} (id INT)`
const startTime = Date.now()
const results = sqlEventParser.getTableEvents(sql)
const duration = Date.now() - startTime
expect(duration).toBeLessThan(100)
expect(results.length).toBeGreaterThan(0)
})
it('handles pathological SELECT INTO patterns', () => {
const maliciousSQL = 'SELECT ' + 'a '.repeat(1000) + 'INTO table FROM users'
const startTime = Date.now()
const results = sqlEventParser.getTableEvents(maliciousSQL)
const duration = Date.now() - startTime
expect(duration).toBeLessThan(100)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'table',
})
})
it('handles ALTER TABLE with many operations between', () => {
const manyOperations = 'ADD COLUMN test INT, '.repeat(100)
const sql = `ALTER TABLE users ${manyOperations} ENABLE ROW LEVEL SECURITY`
const startTime = Date.now()
const results = sqlEventParser.getTableEvents(sql)
const duration = Date.now() - startTime
expect(duration).toBeLessThan(100)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
schema: undefined,
tableName: 'users',
})
})
it('handles mixed quotes and backticks efficiently', () => {
const mixedQuotes = '`"`.'.repeat(100) + 'tablename'
const sql = `CREATE TABLE ${mixedQuotes} (id INT)`
const startTime = Date.now()
sqlEventParser.getTableEvents(sql)
const duration = Date.now() - startTime
expect(duration).toBeLessThan(100)
})
})
describe('Edge cases and special characters', () => {
it('handles Unicode identifiers', () => {
const sql = 'CREATE TABLE 用户表 (id INT)'
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(0)
})
it('handles identifiers with numbers', () => {
const sql = 'CREATE TABLE table123 (id INT)'
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'table123',
})
})
it('handles identifiers with underscores', () => {
const sql = 'CREATE TABLE user_accounts (id INT)'
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'user_accounts',
})
})
it('handles escaped quotes in identifiers', () => {
const sql = 'CREATE TABLE "user""table" (id INT)'
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'usertable',
})
})
it('handles dollar-quoted strings in SQL', () => {
const sql = `
CREATE TABLE users (id INT);
INSERT INTO logs VALUES ($$CREATE TABLE fake$$);
INSERT INTO users VALUES (1);
`
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(3)
expect(results[0].type).toBe(TABLE_EVENT_ACTIONS.TableCreated)
expect(results[0]).toMatchObject({ tableName: 'users' })
expect(results[1].type).toBe(TABLE_EVENT_ACTIONS.TableCreated)
expect(results[1]).toMatchObject({ tableName: 'fake' })
expect(results[2].type).toBe(TABLE_EVENT_ACTIONS.TableDataAdded)
})
it('handles SQL injection attempts safely', () => {
const sql = "CREATE TABLE users'; DROP TABLE users; -- (id INT)"
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: TABLE_EVENT_ACTIONS.TableCreated,
schema: undefined,
tableName: 'users',
})
})
})
describe('getTableEvents', () => {
it('filters only table-related events', () => {
const sql = `
CREATE TABLE users (id INT);
CREATE FUNCTION test() RETURNS INT AS $$ BEGIN RETURN 1; END; $$ LANGUAGE plpgsql;
INSERT INTO users (id) VALUES (1);
ALTER TABLE users ENABLE RLS;
CREATE VIEW user_view AS SELECT * FROM users;
`
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(3)
expect(results.map((r) => r.type)).toEqual([
TABLE_EVENT_ACTIONS.TableCreated,
TABLE_EVENT_ACTIONS.TableDataAdded,
TABLE_EVENT_ACTIONS.TableRLSEnabled,
])
})
it('returns empty array for non-table SQL', () => {
const sql = `
CREATE FUNCTION test() RETURNS INT AS $$ BEGIN RETURN 1; END; $$ LANGUAGE plpgsql;
CREATE VIEW user_view AS SELECT * FROM users;
SELECT * FROM users;
`
const results = sqlEventParser.getTableEvents(sql)
expect(results).toHaveLength(0)
})
})
})
+128
View File
@@ -0,0 +1,128 @@
/**
* Lightweight SQL parser for telemetry event detection.
*
* [Sean] Replace this with a proper SQL parser like `@supabase/pg-parser` once a
* browser-compatible version is available.
*/
import { TABLE_EVENT_ACTIONS, TableEventAction } from 'common/telemetry-constants'
export interface TableEventDetails {
type: TableEventAction
schema?: string
tableName?: string
}
type Detector = {
type: TableEventAction
patterns: RegExp[]
}
export class SQLEventParser {
private static DETECTORS: Detector[] = [
{
type: TABLE_EVENT_ACTIONS.TableCreated,
patterns: [
/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+)/i,
/CREATE\s+TEMP(?:ORARY)?\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+)/i,
/CREATE\s+UNLOGGED\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+)/i,
/SELECT\s+.*?\s+INTO\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+)/is,
/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+)\s+AS\s+SELECT/i,
],
},
{
type: TABLE_EVENT_ACTIONS.TableDataAdded,
patterns: [
/INSERT\s+INTO\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+)/i,
/COPY\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+)\s+FROM/i,
],
},
{
type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
patterns: [
/ALTER\s+TABLE\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+).*?ENABLE\s+ROW\s+LEVEL\s+SECURITY/i,
/ALTER\s+TABLE\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>[\w"`]+).*?ENABLE\s+RLS/i,
],
},
]
private cleanIdentifier(identifier?: string) {
return identifier?.replace(/["`']/g, '').replace(/\.$/, '')
}
private match(sql: string): TableEventDetails | null {
for (const { type, patterns } of SQLEventParser.DETECTORS) {
for (const pattern of patterns) {
const match = sql.match(pattern)
if (match?.groups) {
return {
type,
schema: this.cleanIdentifier(match.groups.schema),
tableName: this.cleanIdentifier(match.groups.table ?? match.groups.object),
}
}
}
}
return null
}
private splitStatements(sql: string): string[] {
// Regex matches:
// - single quotes ('...') with escapes
// - double quotes ("...")
// - dollar-quoted blocks ($$...$$ or $tag$...$tag$)
// - semicolons
// - everything else
const tokens =
sql.match(
/'([^']|'')*'|"([^"]|"")*"|\$[a-zA-Z0-9_]*\$[\s\S]*?\$[a-zA-Z0-9_]*\$|;|[^'"$;]+/g
) || []
const statements: string[] = []
let current = ''
for (const token of tokens) {
if (token === ';') {
if (current.trim()) statements.push(current.trim())
current = ''
} else {
current += token
}
}
if (current.trim()) {
statements.push(current.trim())
}
return statements
}
private deduplicate(events: TableEventDetails[]): TableEventDetails[] {
const seen = new Set<string>()
return events.filter((e) => {
const key = `${e.type}:${e.schema || ''}:${e.tableName || ''}`
if (seen.has(key)) return false
seen.add(key)
return true
})
}
private removeComments(sql: string): string {
return sql
.replace(/--.*?$/gm, '') // line comments
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
}
getTableEvents(sql: string): TableEventDetails[] {
const statements = this.splitStatements(this.removeComments(sql))
const results: TableEventDetails[] = []
for (const stmt of statements) {
const event = this.match(stmt)
if (event) results.push(event)
}
return this.deduplicate(results)
}
}
export const sqlEventParser = new SQLEventParser()
+91
View File
@@ -14,6 +14,16 @@ type TelemetryGroups = {
organization: string
}
export const TABLE_EVENT_ACTIONS = {
TableCreated: 'table_created',
TableDataAdded: 'table_data_added',
TableRLSEnabled: 'table_rls_enabled',
} as const
export type TableEventAction = (typeof TABLE_EVENT_ACTIONS)[keyof typeof TABLE_EVENT_ACTIONS]
export const TABLE_EVENT_VALUES: TableEventAction[] = Object.values(TABLE_EVENT_ACTIONS)
/**
* Triggered when a user signs up. When signing up with Email and Password, this is only triggered once user confirms their email.
*
@@ -1744,6 +1754,84 @@ export interface HipaaRequestButtonClickedEvent {
groups: Omit<TelemetryGroups, 'project'>
}
/**
* User successfully created a table in the project.
*
* @group Events
* @source studio
* @page /dashboard/project/{ref}/editor or /dashboard/project/{ref}/sql
*/
export interface TableCreatedEvent {
action: 'table_created'
properties: {
/**
* Method used to create the table
*/
method: 'sql_editor' | 'table_editor'
/**
* Schema where table was created
*/
schema_name?: string
/**
* Name of the table created
*/
table_name?: string
}
groups: Partial<TelemetryGroups>
}
/**
* User successfully added data to a table.
*
* @group Events
* @source studio
* @page /dashboard/project/{ref}/editor or /dashboard/project/{ref}/sql
*/
export interface TableDataAddedEvent {
action: 'table_data_added'
properties: {
/**
* Method used to insert data
*/
method: 'sql_editor' | 'table_editor' | 'spreadsheet_import'
/**
* Schema of the table
*/
schema_name?: string
/**
* Name of the table
*/
table_name?: string
}
groups: Partial<TelemetryGroups>
}
/**
* User successfully enabled RLS on a table.
*
* @group Events
* @source studio
* @page /dashboard/project/{ref}/editor or /dashboard/project/{ref}/sql
*/
export interface TableRLSEnabledEvent {
action: 'table_rls_enabled'
properties: {
/**
* Method used to enable RLS
*/
method: 'sql_editor' | 'table_editor'
/**
* Schema of the table
*/
schema_name?: string
/**
* Name of the table
*/
table_name?: string
}
groups: Partial<TelemetryGroups>
}
/**
* @hidden
*/
@@ -1851,3 +1939,6 @@ export type TelemetryEvent =
| DpaRequestButtonClickedEvent
| DocumentViewButtonClickedEvent
| HipaaRequestButtonClickedEvent
| TableCreatedEvent
| TableDataAddedEvent
| TableRLSEnabledEvent