feat(sqlserver): add activity trace

This commit is contained in:
zipg authored and GitHub committed 2026-08-13 11:49:54 +08:00
1 parent e5a3917f7d
commit 982778128e
24 files changed
+2108 -10

No files matched your search

@@ -0,0 +1,154 @@
import { describe, expect, it } from "vitest";
import type { QueryResult } from "@/types/database";
import {
buildCreateSqlServerTraceSessionSql,
buildDropSqlServerTraceSessionSql,
buildCleanupSqlServerTraceSessionsSql,
buildListSqlServerTraceSessionsSql,
buildReadSqlServerTraceEventsSql,
buildSqlServerTraceCapabilitiesSql,
buildSqlServerTraceSessionName,
missingSqlServerTraceCapabilities,
normalizeSqlServerTraceDurationMinutes,
normalizeSqlServerTraceMaxEvents,
mergeSqlServerTraceEventSnapshot,
parseSqlServerTraceCapabilities,
parseSqlServerTraceEvents,
sqlServerTraceCapabilityProblem,
sqlServerTraceSessionExpiresAt,
sqlServerTraceEventsToCsv,
staleSqlServerTraceSessionNames,
} from "../sqlServerActivityTrace";
function result(columns: string[], rows: QueryResult["rows"]): QueryResult {
return { columns, rows, affected_rows: 0, execution_time_ms: 0 };
}
describe("SQL Server activity trace", () => {
it("builds bounded, database-scoped XE sessions", () => {
const sql = buildCreateSqlServerTraceSessionSql({
sessionName: "DBX_TRACE_ABC_123",
databaseId: 7,
maxEvents: 99_999,
includeStatements: true,
});
expect(sql).toContain("ADD EVENT sqlserver.rpc_completed");
expect(sql).toContain("ADD EVENT sqlserver.sql_batch_completed");
expect(sql).toContain("ADD EVENT sqlserver.sp_statement_completed");
expect(sql).toContain("[sqlserver].[database_id]=(7)");
expect(sql).toContain("like_i_sql_unicode_string");
expect(sql).toContain("max_events_limit=(256)");
expect(sql).toContain("STARTUP_STATE=OFF");
});
it("rejects unsafe session names and escapes database literals", () => {
expect(() => buildReadSqlServerTraceEventsSql("DBX_TRACE_X]; DROP TABLE x;--")).toThrow(/Invalid/);
expect(() => buildDropSqlServerTraceSessionSql("other_session")).toThrow(/Invalid/);
expect(buildSqlServerTraceCapabilitiesSql("db'o")).toContain("DB_ID(N'db''o')");
});
it("normalizes resource limits and generates safe names", () => {
expect(normalizeSqlServerTraceMaxEvents(1)).toBe(100);
expect(normalizeSqlServerTraceMaxEvents(9000)).toBe(5000);
expect(normalizeSqlServerTraceDurationMinutes(0)).toBe(1);
expect(normalizeSqlServerTraceDurationMinutes(99)).toBe(60);
expect(buildSqlServerTraceSessionName(123456789, 0.25)).toMatch(/^DBX_TRACE_[A-Z0-9]+_[A-Z0-9]{8}$/);
expect(sqlServerTraceSessionExpiresAt(buildSqlServerTraceSessionName(123456789, 0.25))).toBe(123456789);
});
it("identifies only expired DBX sessions for cleanup", () => {
const now = Date.UTC(2026, 7, 12, 12);
const oldName = buildSqlServerTraceSessionName(now - 1, 0.1);
const activeName = buildSqlServerTraceSessionName(now + 10 * 60_000, 0.2);
const stale = staleSqlServerTraceSessionNames(result(["name"], [[oldName], [activeName], ["OTHER_TRACE"]]), now);
expect(stale).toEqual([oldName]);
expect(buildListSqlServerTraceSessionsSql()).toContain("DBX_TRACE_%");
const cleanup = buildCleanupSqlServerTraceSessionsSql(stale);
expect(cleanup).toContain(`ALTER EVENT SESSION [${oldName}]`);
expect(cleanup).toContain(`DROP EVENT SESSION [${oldName}]`);
expect(cleanup).not.toContain(activeName);
});
it("parses capabilities and applies version-specific permission rules", () => {
const capabilities = parseSqlServerTraceCapabilities(
result(
[
"product_version",
"engine_edition",
"database_id",
"can_alter_event_session",
"can_view_server_state",
"can_view_server_performance_state",
"has_rpc_completed_event",
"has_sql_batch_completed_event",
"has_sp_statement_completed_event",
"has_ring_buffer_target",
"has_database_id_predicate",
"has_like_predicate",
"available_actions",
],
[["16.0.4225.2", 3, 5, 1, 1, 1, 1, 1, 1, 1, 1, 1, "client_app_name,client_hostname,database_id,database_name,server_principal_name,session_id,sql_text"]],
),
);
expect(capabilities.majorVersion).toBe(16);
expect(sqlServerTraceCapabilityProblem(capabilities)).toBeNull();
expect(sqlServerTraceCapabilityProblem({ ...capabilities, canViewServerPerformanceState: false })).toBe("view-permission");
expect(sqlServerTraceCapabilityProblem({ ...capabilities, engineEdition: 5 })).toBe("unsupported-engine");
expect(sqlServerTraceCapabilityProblem({ ...capabilities, engineEdition: 11 })).toBe("unsupported-engine");
expect(sqlServerTraceCapabilityProblem({ ...capabilities, hasRingBufferTarget: false })).toBe("missing-capability");
expect(missingSqlServerTraceCapabilities({ ...capabilities, hasRingBufferTarget: false }, false)).toEqual(["package0.ring_buffer"]);
expect(sqlServerTraceCapabilityProblem({ ...capabilities, hasSpStatementCompletedEvent: false }, false)).toBeNull();
expect(sqlServerTraceCapabilityProblem({ ...capabilities, hasSpStatementCompletedEvent: false }, true)).toBe("missing-capability");
});
it("bounds each ring-buffer parse and reads incrementally from the timestamp cursor", () => {
const sql = buildReadSqlServerTraceEventsSql("DBX_TRACE_ABC_123", "2026-08-12T13:23:45.0840000");
expect(sql).toContain("SELECT TOP (256) *");
expect(sql).toContain("event_time_utc >= CONVERT(datetime2(7), N'2026-08-12T13:23:45.0840000', 126)");
expect(sql).toContain("ORDER BY event_time_utc DESC");
});
it("maps XE rows by column name, converts microseconds and removes internal events", () => {
const events = parseSqlServerTraceEvents(
result(
["event_name", "event_time_utc", "database_name", "sql_text", "duration_us", "cpu_time_us", "logical_reads", "writes", "row_count", "session_id", "login_name", "host_name", "client_app", "result_text"],
[
["sql_batch_completed", "2026-08-12 13:23:45.084", "demo", "SELECT 1", 60_429, "6000", 8, 1, 1, 69, "sa", "host", "dbx", "OK"],
["sql_batch_completed", "2026-08-12 13:23:46.000", "demo", "/* DBX_INTERNAL_TRACE */ SELECT 1", 100, 0, 0, 0, 0, 70, "sa", "host", "dbx", "OK"],
],
),
);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({ durationMs: 60.429, cpuMs: 6, sessionId: 69, logicalReads: 8 });
expect(events[0].key).toContain("SELECT 1");
});
it("preserves repeated identical events while deduplicating later snapshots", () => {
const event = parseSqlServerTraceEvents(
result(
["event_name", "event_time_utc", "database_name", "sql_text", "duration_us", "cpu_time_us", "logical_reads", "writes", "row_count", "session_id", "login_name", "host_name", "client_app", "result_text"],
[["sql_batch_completed", "2026-08-12", "demo", "EXEC repeat_me", 1000, 0, 0, 0, 0, 2, "sa", "host", "app", "OK"]],
),
)[0];
const first = mergeSqlServerTraceEventSnapshot(new Map(), [event, event]);
expect(first.additions).toHaveLength(2);
expect(new Set(first.additions.map((item) => item.key)).size).toBe(2);
const second = mergeSqlServerTraceEventSnapshot(first.counts, [event, event, event]);
expect(second.additions).toHaveLength(1);
expect(second.counts.get(event.key)).toBe(3);
});
it("exports UTF-8 CSV with formula-safe quoting preserved as data", () => {
const event = parseSqlServerTraceEvents(
result(
["event_name", "event_time_utc", "database_name", "sql_text", "duration_us", "cpu_time_us", "logical_reads", "writes", "row_count", "session_id", "login_name", "host_name", "client_app", "result_text"],
[["rpc_completed", "2026-08-12", "demo", "EXEC p N'a,b'", 1000, 0, 0, 0, 1, 2, "sa", "host", "app", "OK"]],
),
)[0];
const csv = sqlServerTraceEventsToCsv([event]);
expect(csv.startsWith("\uFEFFtime_utc")).toBe(true);
expect(csv).toContain("\"EXEC p N'a,b'\"");
expect(csv.endsWith("\r\n")).toBe(true);
expect(sqlServerTraceEventsToCsv([{ ...event, sqlText: "=cmd()" }])).toContain("'=cmd()");
});
});
@@ -0,0 +1,186 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { QueryResult } from "@/types/database";
const mocks = vi.hoisted(() => ({
executeQuery: vi.fn(),
cancelQuery: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => mocks);
function result(columns: string[] = [], rows: QueryResult["rows"] = []): QueryResult {
return { columns, rows, affected_rows: 0, execution_time_ms: 0 };
}
function capabilityResult(): QueryResult {
return result(
[
"product_version",
"engine_edition",
"database_id",
"can_alter_event_session",
"can_view_server_state",
"can_view_server_performance_state",
"has_rpc_completed_event",
"has_sql_batch_completed_event",
"has_sp_statement_completed_event",
"has_ring_buffer_target",
"has_database_id_predicate",
"has_like_predicate",
"available_actions",
],
[["16.0.4225.2", 3, 5, 1, 1, 1, 1, 1, 1, 1, 1, 1, "client_app_name,client_hostname,database_id,database_name,server_principal_name,session_id,sql_text"]],
);
}
function installLocalStorage() {
const data = new Map<string, string>();
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => data.get(key) ?? null),
setItem: vi.fn((key: string, value: string) => data.set(key, value)),
removeItem: vi.fn((key: string) => data.delete(key)),
});
return data;
}
function waitFor(predicate: () => boolean): Promise<void> {
return new Promise((resolve, reject) => {
const deadline = Date.now() + 2000;
const inspect = () => {
if (predicate()) resolve();
else if (Date.now() >= deadline) reject(new Error("Timed out waiting for trace runtime state"));
else setTimeout(inspect, 0);
};
inspect();
});
}
describe("SQL Server activity trace runtime", () => {
beforeEach(async () => {
vi.clearAllMocks();
vi.unstubAllGlobals();
installLocalStorage();
const { disposeAllSqlServerActivityTraces } = await import("../sqlServerActivityTraceRuntime");
await disposeAllSqlServerActivityTraces();
});
afterEach(async () => {
const { disposeAllSqlServerActivityTraces } = await import("../sqlServerActivityTraceRuntime");
await disposeAllSqlServerActivityTraces();
vi.unstubAllGlobals();
});
it("keeps trace state in the tab runtime until the tab is explicitly disposed", async () => {
mocks.executeQuery.mockImplementation(async (_connectionId: string, _database: string, sql: string) => {
if (sql.includes("sys.dm_xe_objects")) return capabilityResult();
return result(sql.includes("sys.server_event_sessions") && sql.includes("SELECT name") ? ["name"] : [], []);
});
mocks.cancelQuery.mockResolvedValue(true);
const { activeSqlServerActivityTraceRuntimeCount, disposeSqlServerActivityTrace, getSqlServerActivityTraceRuntime } = await import("../sqlServerActivityTraceRuntime");
const runtime = getSqlServerActivityTraceRuntime("tab-1", "sqlserver-1", "app");
await runtime.start();
runtime.state.sqlFilter = "orders";
const recreatedComponentRuntime = getSqlServerActivityTraceRuntime("tab-1", "sqlserver-1", "app");
expect(recreatedComponentRuntime).toBe(runtime);
expect(recreatedComponentRuntime.state.status).toBe("running");
expect(recreatedComponentRuntime.state.sqlFilter).toBe("orders");
expect(activeSqlServerActivityTraceRuntimeCount()).toBe(1);
await disposeSqlServerActivityTrace("tab-1");
expect(activeSqlServerActivityTraceRuntimeCount()).toBe(0);
expect(mocks.executeQuery.mock.calls.some((call) => String(call[2]).includes("STATE = STOP"))).toBe(true);
expect(mocks.executeQuery.mock.calls.some((call) => String(call[2]).includes("DROP EVENT SESSION"))).toBe(true);
});
it("cancels an in-flight incremental read before dropping the session", async () => {
let settleRead: ((value: QueryResult) => void) | undefined;
mocks.executeQuery.mockImplementation(async (_connectionId: string, _database: string, sql: string) => {
if (sql.includes("sys.dm_xe_objects")) return capabilityResult();
if (sql.includes("CROSS APPLY target_data.nodes")) {
return new Promise<QueryResult>((resolve) => {
settleRead = resolve;
});
}
return result(sql.includes("sys.server_event_sessions") && sql.includes("SELECT name") ? ["name"] : [], []);
});
mocks.cancelQuery.mockImplementation(async () => {
settleRead?.(result());
return true;
});
const { disposeSqlServerActivityTrace, getSqlServerActivityTraceRuntime } = await import("../sqlServerActivityTraceRuntime");
const runtime = getSqlServerActivityTraceRuntime("tab-2", "sqlserver-1", "app");
const starting = runtime.start();
await waitFor(() => runtime.state.polling);
const readCall = mocks.executeQuery.mock.calls.find((call) => String(call[2]).includes("CROSS APPLY target_data.nodes"));
const executionId = readCall?.[4];
await disposeSqlServerActivityTrace("tab-2");
await starting;
expect(typeof executionId).toBe("string");
expect(mocks.cancelQuery).toHaveBeenCalledWith(executionId);
const cancelOrder = mocks.cancelQuery.mock.invocationCallOrder[0];
const finalDropOrder = mocks.executeQuery.mock.calls.map((call, index) => (String(call[2]).includes("DROP EVENT SESSION") ? mocks.executeQuery.mock.invocationCallOrder[index] : 0)).find((order) => order > cancelOrder);
expect(finalDropOrder).toBeDefined();
});
it("waits for an in-flight session creation before disposing the tab", async () => {
let finishCreate: (() => void) | undefined;
mocks.executeQuery.mockImplementation(async (_connectionId: string, _database: string, sql: string) => {
if (sql.includes("sys.dm_xe_objects")) return capabilityResult();
if (sql.includes("CREATE EVENT SESSION")) {
await new Promise<void>((resolve) => {
finishCreate = resolve;
});
}
return result(sql.includes("sys.server_event_sessions") && sql.includes("SELECT name") ? ["name"] : [], []);
});
mocks.cancelQuery.mockResolvedValue(true);
const { disposeSqlServerActivityTrace, getSqlServerActivityTraceRuntime } = await import("../sqlServerActivityTraceRuntime");
const runtime = getSqlServerActivityTraceRuntime("tab-starting", "sqlserver-1", "app");
const starting = runtime.start();
await waitFor(() => mocks.executeQuery.mock.calls.some((call) => String(call[2]).includes("CREATE EVENT SESSION")));
const disposing = disposeSqlServerActivityTrace("tab-starting");
await Promise.resolve();
expect(mocks.executeQuery.mock.calls.some((call) => String(call[2]).includes("DROP EVENT SESSION") && !String(call[2]).includes("CREATE EVENT SESSION"))).toBe(false);
finishCreate?.();
await Promise.all([starting, disposing]);
const createOrder = mocks.executeQuery.mock.calls.map((call, index) => (String(call[2]).includes("CREATE EVENT SESSION") ? mocks.executeQuery.mock.invocationCallOrder[index] : 0)).find(Boolean);
const dropOrder = mocks.executeQuery.mock.calls.map((call, index) => (String(call[2]).includes("DROP EVENT SESSION") && !String(call[2]).includes("CREATE EVENT SESSION") ? mocks.executeQuery.mock.invocationCallOrder[index] : 0)).find((order) => order > (createOrder ?? 0));
expect(dropOrder).toBeDefined();
expect(runtime.state.sessionName).toBe("");
});
it("cleans persisted crash leftovers independently on the next connection", async () => {
const data = installLocalStorage();
const expiresAt = Date.now() - 1;
const { buildSqlServerTraceSessionName } = await import("../sqlServerActivityTrace");
const sessionName = buildSqlServerTraceSessionName(expiresAt, 0.25);
const activeExpiresAt = Date.now() + 60_000;
const activeSessionName = buildSqlServerTraceSessionName(activeExpiresAt, 0.5);
data.set(
"dbx:sqlserver-trace:pending-sessions:v1",
JSON.stringify([
{ connectionId: "sqlserver-1", database: "app", sessionName, expiresAt },
{ connectionId: "sqlserver-1", database: "app", sessionName: activeSessionName, expiresAt: activeExpiresAt },
]),
);
mocks.executeQuery.mockImplementation(async (_connectionId: string, _database: string, sql: string) => {
if (sql.includes("SELECT name") && sql.includes("sys.server_event_sessions")) return result(["name"], [[sessionName], [activeSessionName]]);
return result();
});
const { cleanupStaleSqlServerTraceSessions } = await import("../sqlServerActivityTraceRuntime");
expect(await cleanupStaleSqlServerTraceSessions("sqlserver-1", "app")).toBe(1);
expect(mocks.executeQuery.mock.calls.some((call) => String(call[2]).includes(`DROP EVENT SESSION [${sessionName}]`))).toBe(true);
expect(mocks.executeQuery.mock.calls.some((call) => String(call[2]).includes(`DROP EVENT SESSION [${activeSessionName}]`))).toBe(false);
expect(JSON.parse(data.get("dbx:sqlserver-trace:pending-sessions:v1") || "[]")).toEqual([{ connectionId: "sqlserver-1", database: "app", sessionName: activeSessionName, expiresAt: activeExpiresAt }]);
});
});
@@ -0,0 +1,425 @@
import type { QueryResult } from "@/types/database";
export const SQLSERVER_TRACE_SESSION_PREFIX = "DBX_TRACE_";
export const SQLSERVER_TRACE_INTERNAL_MARKER = "DBX_INTERNAL_TRACE";
export const SQLSERVER_TRACE_DEFAULT_MAX_EVENTS = 1000;
export const SQLSERVER_TRACE_MIN_MAX_EVENTS = 100;
export const SQLSERVER_TRACE_MAX_MAX_EVENTS = 5000;
export const SQLSERVER_TRACE_DEFAULT_DURATION_MINUTES = 10;
export const SQLSERVER_TRACE_MIN_DURATION_MINUTES = 1;
export const SQLSERVER_TRACE_MAX_DURATION_MINUTES = 60;
export const SQLSERVER_TRACE_STALE_GRACE_MINUTES = 5;
export const SQLSERVER_TRACE_RING_BUFFER_MAX_EVENTS = 256;
export const SQLSERVER_TRACE_READ_BATCH_SIZE = 256;
export interface SqlServerTraceCapabilities {
productVersion: string;
majorVersion: number;
engineEdition: number;
databaseId: number;
canAlterEventSession: boolean;
canViewServerState: boolean;
canViewServerPerformanceState: boolean;
hasRpcCompletedEvent: boolean;
hasSqlBatchCompletedEvent: boolean;
hasSpStatementCompletedEvent: boolean;
hasRingBufferTarget: boolean;
hasDatabaseIdPredicate: boolean;
hasLikePredicate: boolean;
availableActions: Set<string>;
}
export interface SqlServerTraceEvent {
key: string;
eventName: string;
timestamp: string;
database: string;
sqlText: string;
durationMs: number | null;
cpuMs: number | null;
logicalReads: number | null;
writes: number | null;
rowCount: number | null;
sessionId: number | null;
loginName: string;
hostName: string;
clientApp: string;
result: string;
}
export interface SqlServerTraceSessionOptions {
sessionName: string;
databaseId: number;
maxEvents: number;
includeStatements: boolean;
}
type QueryCell = string | number | boolean | null;
function clampInteger(value: number, min: number, max: number, fallback: number): number {
if (!Number.isFinite(value)) return fallback;
return Math.min(max, Math.max(min, Math.round(value)));
}
export function normalizeSqlServerTraceMaxEvents(value: number): number {
return clampInteger(value, SQLSERVER_TRACE_MIN_MAX_EVENTS, SQLSERVER_TRACE_MAX_MAX_EVENTS, SQLSERVER_TRACE_DEFAULT_MAX_EVENTS);
}
export function normalizeSqlServerTraceDurationMinutes(value: number): number {
return clampInteger(value, SQLSERVER_TRACE_MIN_DURATION_MINUTES, SQLSERVER_TRACE_MAX_DURATION_MINUTES, SQLSERVER_TRACE_DEFAULT_DURATION_MINUTES);
}
export function buildSqlServerTraceSessionName(expiresAt = Date.now() + (SQLSERVER_TRACE_MAX_DURATION_MINUTES + SQLSERVER_TRACE_STALE_GRACE_MINUTES) * 60_000, random = Math.random()): string {
const timePart = Math.max(0, Math.floor(expiresAt)).toString(36).toUpperCase();
const randomPart = Math.max(0, Math.min(0.999999999, random)).toString(36).slice(2, 10).toUpperCase().padEnd(8, "0");
return `${SQLSERVER_TRACE_SESSION_PREFIX}${timePart}_${randomPart}`;
}
export function isValidSqlServerTraceSessionName(value: string): boolean {
return /^DBX_TRACE_[A-Z0-9_]{3,100}$/.test(value);
}
function assertSessionName(sessionName: string): string {
if (!isValidSqlServerTraceSessionName(sessionName)) throw new Error("Invalid SQL Server trace session name");
return sessionName;
}
function sqlString(value: string): string {
return `N'${value.replaceAll("'", "''")}'`;
}
function internalSql(sql: string): string {
return `/* ${SQLSERVER_TRACE_INTERNAL_MARKER} */\n${sql}`;
}
export function buildSqlServerTraceCapabilitiesSql(database: string): string {
return internalSql(`SELECT
CAST(SERVERPROPERTY(N'ProductVersion') AS nvarchar(128)) AS product_version,
CONVERT(int, SERVERPROPERTY(N'EngineEdition')) AS engine_edition,
DB_ID(${sqlString(database)}) AS database_id,
CONVERT(int, HAS_PERMS_BY_NAME(NULL, NULL, N'ALTER ANY EVENT SESSION')) AS can_alter_event_session,
CONVERT(int, HAS_PERMS_BY_NAME(NULL, NULL, N'VIEW SERVER STATE')) AS can_view_server_state,
CONVERT(int, CASE
WHEN EXISTS (SELECT 1 FROM sys.fn_builtin_permissions(DEFAULT) WHERE permission_name = N'VIEW SERVER PERFORMANCE STATE')
THEN HAS_PERMS_BY_NAME(NULL, NULL, N'VIEW SERVER PERFORMANCE STATE')
ELSE 1
END) AS can_view_server_performance_state,
CONVERT(int, CASE WHEN EXISTS (
SELECT 1 FROM sys.dm_xe_objects object
JOIN sys.dm_xe_packages package ON package.guid = object.package_guid
WHERE package.name = N'sqlserver' AND object.object_type = N'event' AND object.name = N'rpc_completed'
) THEN 1 ELSE 0 END) AS has_rpc_completed_event,
CONVERT(int, CASE WHEN EXISTS (
SELECT 1 FROM sys.dm_xe_objects object
JOIN sys.dm_xe_packages package ON package.guid = object.package_guid
WHERE package.name = N'sqlserver' AND object.object_type = N'event' AND object.name = N'sql_batch_completed'
) THEN 1 ELSE 0 END) AS has_sql_batch_completed_event,
CONVERT(int, CASE WHEN EXISTS (
SELECT 1 FROM sys.dm_xe_objects object
JOIN sys.dm_xe_packages package ON package.guid = object.package_guid
WHERE package.name = N'sqlserver' AND object.object_type = N'event' AND object.name = N'sp_statement_completed'
) THEN 1 ELSE 0 END) AS has_sp_statement_completed_event,
CONVERT(int, CASE WHEN EXISTS (
SELECT 1 FROM sys.dm_xe_objects object
JOIN sys.dm_xe_packages package ON package.guid = object.package_guid
WHERE package.name = N'package0' AND object.object_type = N'target' AND object.name = N'ring_buffer'
) THEN 1 ELSE 0 END) AS has_ring_buffer_target,
CONVERT(int, CASE WHEN EXISTS (
SELECT 1 FROM sys.dm_xe_objects object
JOIN sys.dm_xe_packages package ON package.guid = object.package_guid
WHERE package.name = N'sqlserver' AND object.object_type = N'pred_source' AND object.name = N'database_id'
) THEN 1 ELSE 0 END) AS has_database_id_predicate,
CONVERT(int, CASE WHEN EXISTS (
SELECT 1 FROM sys.dm_xe_objects object
JOIN sys.dm_xe_packages package ON package.guid = object.package_guid
WHERE package.name = N'sqlserver' AND object.object_type = N'pred_compare' AND object.name = N'like_i_sql_unicode_string'
) THEN 1 ELSE 0 END) AS has_like_predicate,
STUFF((
SELECT N',' + object.name
FROM sys.dm_xe_objects object
JOIN sys.dm_xe_packages package ON package.guid = object.package_guid
WHERE package.name = N'sqlserver'
AND object.object_type = N'action'
AND object.name IN (
N'client_app_name', N'client_hostname', N'database_id', N'database_name',
N'server_principal_name', N'session_id', N'sql_text'
)
ORDER BY object.name
FOR XML PATH(N''), TYPE
).value(N'.', N'nvarchar(max)'), 1, 1, N'') AS available_actions;`);
}
function columnIndex(result: QueryResult, name: string): number {
return result.columns.findIndex((column) => column.toLowerCase() === name.toLowerCase());
}
function cell(result: QueryResult, row: QueryCell[], name: string): QueryCell {
const index = columnIndex(result, name);
return index >= 0 ? row[index] : null;
}
function textValue(value: QueryCell): string {
return value == null ? "" : String(value);
}
function numberValue(value: QueryCell): number | null {
if (value == null || value === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function booleanValue(value: QueryCell): boolean {
if (typeof value === "boolean") return value;
return numberValue(value) === 1 || String(value).toLowerCase() === "true";
}
export function parseSqlServerTraceCapabilities(result: QueryResult): SqlServerTraceCapabilities {
const row = result.rows[0];
if (!row) throw new Error("SQL Server did not return trace capability information");
const productVersion = textValue(cell(result, row, "product_version"));
const majorVersion = Number.parseInt(productVersion.split(".")[0] || "", 10);
const databaseId = numberValue(cell(result, row, "database_id"));
const engineEdition = numberValue(cell(result, row, "engine_edition"));
if (!Number.isInteger(majorVersion) || !Number.isInteger(databaseId) || !Number.isInteger(engineEdition)) {
throw new Error("SQL Server returned incomplete trace capability information");
}
return {
productVersion,
majorVersion,
engineEdition: engineEdition!,
databaseId: databaseId!,
canAlterEventSession: booleanValue(cell(result, row, "can_alter_event_session")),
canViewServerState: booleanValue(cell(result, row, "can_view_server_state")),
canViewServerPerformanceState: booleanValue(cell(result, row, "can_view_server_performance_state")),
hasRpcCompletedEvent: booleanValue(cell(result, row, "has_rpc_completed_event")),
hasSqlBatchCompletedEvent: booleanValue(cell(result, row, "has_sql_batch_completed_event")),
hasSpStatementCompletedEvent: booleanValue(cell(result, row, "has_sp_statement_completed_event")),
hasRingBufferTarget: booleanValue(cell(result, row, "has_ring_buffer_target")),
hasDatabaseIdPredicate: booleanValue(cell(result, row, "has_database_id_predicate")),
hasLikePredicate: booleanValue(cell(result, row, "has_like_predicate")),
availableActions: new Set(
textValue(cell(result, row, "available_actions"))
.split(",")
.filter(Boolean),
),
};
}
const REQUIRED_SQLSERVER_TRACE_ACTIONS = ["client_app_name", "client_hostname", "database_id", "database_name", "server_principal_name", "session_id", "sql_text"] as const;
export function missingSqlServerTraceCapabilities(capabilities: SqlServerTraceCapabilities, includeStatements: boolean): string[] {
const missing: string[] = [];
if (!capabilities.hasRpcCompletedEvent) missing.push("sqlserver.rpc_completed");
if (!capabilities.hasSqlBatchCompletedEvent) missing.push("sqlserver.sql_batch_completed");
if (includeStatements && !capabilities.hasSpStatementCompletedEvent) missing.push("sqlserver.sp_statement_completed");
if (!capabilities.hasRingBufferTarget) missing.push("package0.ring_buffer");
if (!capabilities.hasDatabaseIdPredicate) missing.push("sqlserver.database_id predicate");
if (!capabilities.hasLikePredicate) missing.push("sqlserver.like_i_sql_unicode_string predicate");
for (const action of REQUIRED_SQLSERVER_TRACE_ACTIONS) {
if (!capabilities.availableActions.has(action)) missing.push(`sqlserver.${action} action`);
}
return missing;
}
export type SqlServerTraceCapabilityProblem = "unsupported-version" | "unsupported-engine" | "alter-permission" | "view-permission" | "missing-capability";
export function sqlServerTraceCapabilityProblem(capabilities: SqlServerTraceCapabilities, includeStatements = false): SqlServerTraceCapabilityProblem | null {
if (capabilities.majorVersion < 10) return "unsupported-version";
// Azure SQL Database uses database-scoped XE sessions, which are outside the
// first release. Boxed SQL Server, Managed Instance and SQL Edge use SERVER.
if (![1, 2, 3, 4, 8, 9].includes(capabilities.engineEdition)) return "unsupported-engine";
if (!capabilities.canAlterEventSession) return "alter-permission";
if (capabilities.majorVersion >= 16 ? !capabilities.canViewServerPerformanceState : !capabilities.canViewServerState) return "view-permission";
if (missingSqlServerTraceCapabilities(capabilities, includeStatements).length > 0) return "missing-capability";
return null;
}
function traceEventSql(eventName: "rpc_completed" | "sql_batch_completed" | "sp_statement_completed", databaseId: number): string {
return `ADD EVENT sqlserver.${eventName}(
ACTION(
sqlserver.client_app_name,
sqlserver.client_hostname,
sqlserver.database_id,
sqlserver.database_name,
sqlserver.server_principal_name,
sqlserver.session_id,
sqlserver.sql_text
)
WHERE (
[sqlserver].[database_id]=(${databaseId})
AND NOT [sqlserver].[like_i_sql_unicode_string]([sqlserver].[sql_text],N'%${SQLSERVER_TRACE_INTERNAL_MARKER}%')
))`;
}
export function buildCreateSqlServerTraceSessionSql(options: SqlServerTraceSessionOptions): string {
const sessionName = assertSessionName(options.sessionName);
if (!Number.isInteger(options.databaseId) || options.databaseId <= 0) throw new Error("Invalid SQL Server database id");
const maxEvents = normalizeSqlServerTraceMaxEvents(options.maxEvents);
const events = [traceEventSql("rpc_completed", options.databaseId), traceEventSql("sql_batch_completed", options.databaseId)];
if (options.includeStatements) events.push(traceEventSql("sp_statement_completed", options.databaseId));
return internalSql(`IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = ${sqlString(sessionName)})
DROP EVENT SESSION [${sessionName}] ON SERVER;
CREATE EVENT SESSION [${sessionName}] ON SERVER
${events.join(",\n")}
ADD TARGET package0.ring_buffer(
SET max_events_limit=(${Math.min(maxEvents, SQLSERVER_TRACE_RING_BUFFER_MAX_EVENTS)}), max_memory=(4096)
)
WITH (
MAX_MEMORY=4096 KB,
EVENT_RETENTION_MODE=ALLOW_SINGLE_EVENT_LOSS,
MAX_DISPATCH_LATENCY=1 SECONDS,
TRACK_CAUSALITY=OFF,
STARTUP_STATE=OFF
);
ALTER EVENT SESSION [${sessionName}] ON SERVER STATE = START;`);
}
export function buildReadSqlServerTraceEventsSql(sessionName: string, afterTimestamp?: string): string {
assertSessionName(sessionName);
const cursorPredicate = afterTimestamp ? `WHERE event_time_utc >= CONVERT(datetime2(7), ${sqlString(afterTimestamp.replace(" ", "T"))}, 126)` : "";
return internalSql(`;WITH target AS (
SELECT CAST(target.target_data AS xml) AS target_data
FROM sys.dm_xe_session_targets target
JOIN sys.dm_xe_sessions session ON session.address = target.event_session_address
WHERE session.name = ${sqlString(sessionName)} AND target.target_name = N'ring_buffer'
), event_rows AS (
SELECT
event_node.value('(@name)[1]', 'nvarchar(128)') AS event_name,
event_node.value('(@timestamp)[1]', 'datetime2(7)') AS event_time_utc,
event_node.value('(action[@name="database_name"]/value/text())[1]', 'nvarchar(128)') AS database_name,
COALESCE(
NULLIF(event_node.value('(data[@name="statement"]/value/text())[1]', 'nvarchar(max)'), N''),
NULLIF(event_node.value('(data[@name="batch_text"]/value/text())[1]', 'nvarchar(max)'), N''),
event_node.value('(action[@name="sql_text"]/value/text())[1]', 'nvarchar(max)')
) AS sql_text,
event_node.value('(data[@name="duration"]/value/text())[1]', 'bigint') AS duration_us,
event_node.value('(data[@name="cpu_time"]/value/text())[1]', 'bigint') AS cpu_time_us,
event_node.value('(data[@name="logical_reads"]/value/text())[1]', 'bigint') AS logical_reads,
event_node.value('(data[@name="writes"]/value/text())[1]', 'bigint') AS writes,
event_node.value('(data[@name="row_count"]/value/text())[1]', 'bigint') AS row_count,
event_node.value('(action[@name="session_id"]/value/text())[1]', 'int') AS session_id,
event_node.value('(action[@name="server_principal_name"]/value/text())[1]', 'nvarchar(256)') AS login_name,
event_node.value('(action[@name="client_hostname"]/value/text())[1]', 'nvarchar(256)') AS host_name,
event_node.value('(action[@name="client_app_name"]/value/text())[1]', 'nvarchar(256)') AS client_app,
event_node.value('(data[@name="result"]/text/text())[1]', 'nvarchar(256)') AS result_text
FROM target
CROSS APPLY target_data.nodes('/RingBufferTarget/event') AS events(event_node)
), bounded_events AS (
SELECT TOP (${SQLSERVER_TRACE_READ_BATCH_SIZE}) *
FROM event_rows
${cursorPredicate}
ORDER BY event_time_utc DESC
)
SELECT *
FROM bounded_events
ORDER BY event_time_utc;`);
}
export function buildStopSqlServerTraceSessionSql(sessionName: string): string {
assertSessionName(sessionName);
return internalSql(`IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = ${sqlString(sessionName)})
ALTER EVENT SESSION [${sessionName}] ON SERVER STATE = STOP;`);
}
export function buildDropSqlServerTraceSessionSql(sessionName: string): string {
assertSessionName(sessionName);
return internalSql(`IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = ${sqlString(sessionName)})
DROP EVENT SESSION [${sessionName}] ON SERVER;`);
}
export function buildListSqlServerTraceSessionsSql(): string {
return internalSql(`SELECT name
FROM sys.server_event_sessions
WHERE name LIKE N'${SQLSERVER_TRACE_SESSION_PREFIX}%';`);
}
export function sqlServerTraceSessionExpiresAt(sessionName: string): number | null {
if (!isValidSqlServerTraceSessionName(sessionName)) return null;
const encoded = sessionName.slice(SQLSERVER_TRACE_SESSION_PREFIX.length).split("_")[0];
const timestamp = Number.parseInt(encoded, 36);
return Number.isSafeInteger(timestamp) && timestamp >= 0 ? timestamp : null;
}
export function staleSqlServerTraceSessionNames(result: QueryResult, now = Date.now()): string[] {
return result.rows
.map((row) => textValue(cell(result, row, "name")))
.filter((name) => {
const expiresAt = sqlServerTraceSessionExpiresAt(name);
return expiresAt !== null && expiresAt <= now;
});
}
export function buildCleanupSqlServerTraceSessionsSql(sessionNames: string[]): string {
const names = [...new Set(sessionNames.map(assertSessionName))];
if (names.length === 0) return internalSql("SELECT 0 AS cleaned_sessions;");
return internalSql(
`${names
.map(
(name) => `IF EXISTS (SELECT 1 FROM sys.dm_xe_sessions WHERE name = ${sqlString(name)})
ALTER EVENT SESSION [${name}] ON SERVER STATE = STOP;
IF EXISTS (SELECT 1 FROM sys.server_event_sessions WHERE name = ${sqlString(name)})
DROP EVENT SESSION [${name}] ON SERVER;`,
)
.join("\n")}\nSELECT ${names.length} AS cleaned_sessions;`,
);
}
function microsecondsToMilliseconds(value: QueryCell): number | null {
const microseconds = numberValue(value);
return microseconds === null ? null : microseconds / 1000;
}
function eventKey(event: Omit<SqlServerTraceEvent, "key">): string {
return [event.timestamp, event.eventName, event.sessionId ?? "", event.durationMs ?? "", event.sqlText].join("\u0001");
}
export function parseSqlServerTraceEvents(result: QueryResult): SqlServerTraceEvent[] {
return result.rows
.map((row) => {
const event: Omit<SqlServerTraceEvent, "key"> = {
eventName: textValue(cell(result, row, "event_name")),
timestamp: textValue(cell(result, row, "event_time_utc")),
database: textValue(cell(result, row, "database_name")),
sqlText: textValue(cell(result, row, "sql_text")),
durationMs: microsecondsToMilliseconds(cell(result, row, "duration_us")),
cpuMs: microsecondsToMilliseconds(cell(result, row, "cpu_time_us")),
logicalReads: numberValue(cell(result, row, "logical_reads")),
writes: numberValue(cell(result, row, "writes")),
rowCount: numberValue(cell(result, row, "row_count")),
sessionId: numberValue(cell(result, row, "session_id")),
loginName: textValue(cell(result, row, "login_name")),
hostName: textValue(cell(result, row, "host_name")),
clientApp: textValue(cell(result, row, "client_app")),
result: textValue(cell(result, row, "result_text")),
};
return { ...event, key: eventKey(event) };
})
.filter((event) => event.eventName && !event.sqlText.includes(SQLSERVER_TRACE_INTERNAL_MARKER));
}
export function mergeSqlServerTraceEventSnapshot(existingCounts: ReadonlyMap<string, number>, snapshot: SqlServerTraceEvent[]): { additions: SqlServerTraceEvent[]; counts: Map<string, number> } {
const snapshotCounts = new Map<string, number>();
const additions: SqlServerTraceEvent[] = [];
for (const event of snapshot) {
const occurrence = (snapshotCounts.get(event.key) ?? 0) + 1;
snapshotCounts.set(event.key, occurrence);
if (occurrence > (existingCounts.get(event.key) ?? 0)) {
additions.push({ ...event, key: `${event.key}\u0001${occurrence}` });
}
}
const counts = new Map(existingCounts);
for (const [key, count] of snapshotCounts) counts.set(key, Math.max(counts.get(key) ?? 0, count));
return { additions, counts };
}
function csvCell(value: unknown): string {
const raw = value == null ? "" : String(value);
const text = /^[\t\r]*[=+\-@]/.test(raw) ? `'${raw}` : raw;
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
}
export function sqlServerTraceEventsToCsv(events: SqlServerTraceEvent[]): string {
const header = ["time_utc", "event", "database", "sql_text", "duration_ms", "cpu_ms", "logical_reads", "writes", "row_count", "session_id", "login", "host", "client_app", "result"];
const rows = events.map((event) => [event.timestamp, event.eventName, event.database, event.sqlText, event.durationMs, event.cpuMs, event.logicalReads, event.writes, event.rowCount, event.sessionId, event.loginName, event.hostName, event.clientApp, event.result]);
return `\uFEFF${[header, ...rows].map((row) => row.map(csvCell).join(",")).join("\r\n")}\r\n`;
}
@@ -0,0 +1,434 @@
import { reactive } from "vue";
import { uuid } from "@/lib/common/utils";
import * as api from "@/lib/backend/api";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
import type { QueryResult } from "@/types/database";
import {
SQLSERVER_TRACE_DEFAULT_DURATION_MINUTES,
SQLSERVER_TRACE_DEFAULT_MAX_EVENTS,
SQLSERVER_TRACE_MAX_DURATION_MINUTES,
SQLSERVER_TRACE_READ_BATCH_SIZE,
SQLSERVER_TRACE_STALE_GRACE_MINUTES,
buildCleanupSqlServerTraceSessionsSql,
buildCreateSqlServerTraceSessionSql,
buildDropSqlServerTraceSessionSql,
buildListSqlServerTraceSessionsSql,
buildReadSqlServerTraceEventsSql,
buildSqlServerTraceCapabilitiesSql,
buildSqlServerTraceSessionName,
buildStopSqlServerTraceSessionSql,
isValidSqlServerTraceSessionName,
missingSqlServerTraceCapabilities,
normalizeSqlServerTraceDurationMinutes,
normalizeSqlServerTraceMaxEvents,
parseSqlServerTraceCapabilities,
parseSqlServerTraceEvents,
sqlServerTraceCapabilityProblem,
staleSqlServerTraceSessionNames,
type SqlServerTraceEvent,
type SqlServerTraceCapabilityProblem,
} from "@/lib/sqlserver/sqlServerActivityTrace";
export type SqlServerTraceStatus = "idle" | "starting" | "running" | "paused" | "stopping" | "stopped" | "error";
export type SqlServerTraceAutoStopReason = "duration" | "events";
export interface SqlServerActivityTraceState {
status: SqlServerTraceStatus;
selectedDatabase: string;
includeStatements: boolean;
maxEvents: number;
durationMinutes: number;
events: SqlServerTraceEvent[];
selectedEvent: SqlServerTraceEvent | null;
sqlFilter: string;
loginFilter: string;
clientFilter: string;
sessionFilter: string;
error: string;
sessionName: string;
startedAt: number | null;
elapsedSeconds: number;
polling: boolean;
autoStopReason: SqlServerTraceAutoStopReason | null;
autoStopRevision: number;
capabilityProblem: SqlServerTraceCapabilityProblem | null;
capabilityVersion: string;
missingCapabilities: string[];
}
interface SqlServerTraceCursor {
timestamp: string;
counts: Map<string, number>;
}
interface PendingSqlServerTraceSession {
connectionId: string;
database: string;
sessionName: string;
expiresAt: number;
}
interface PollOptions {
final?: boolean;
allowWhileStopping?: boolean;
}
export interface SqlServerActivityTraceRuntime {
readonly tabId: string;
readonly connectionId: string;
readonly state: SqlServerActivityTraceState;
start(): Promise<void>;
pause(): void;
resume(): Promise<void>;
stop(reason?: SqlServerTraceAutoStopReason): Promise<void>;
clearEvents(): void;
}
const PENDING_SESSIONS_STORAGE_KEY = "dbx:sqlserver-trace:pending-sessions:v1";
const runtimes = new Map<string, InternalSqlServerActivityTraceRuntime>();
const activeSessionNames = new Set<string>();
const staleCleanupTasks = new Map<string, Promise<number>>();
interface InternalSqlServerActivityTraceRuntime extends SqlServerActivityTraceRuntime {
pollTimer?: ReturnType<typeof setInterval>;
elapsedTimer?: ReturnType<typeof setInterval>;
pollPromise?: Promise<void>;
activeReadExecutionId?: string;
generation: number;
cursor?: SqlServerTraceCursor;
startPromise?: Promise<void>;
stopPromise?: Promise<void>;
}
function readPendingSessions(): PendingSqlServerTraceSession[] {
try {
const value = JSON.parse(safeLocalStorageGet(PENDING_SESSIONS_STORAGE_KEY) || "[]") as unknown;
if (!Array.isArray(value)) return [];
return value.filter(
(entry): entry is PendingSqlServerTraceSession =>
!!entry &&
typeof entry === "object" &&
typeof (entry as PendingSqlServerTraceSession).connectionId === "string" &&
typeof (entry as PendingSqlServerTraceSession).database === "string" &&
typeof (entry as PendingSqlServerTraceSession).sessionName === "string" &&
isValidSqlServerTraceSessionName((entry as PendingSqlServerTraceSession).sessionName) &&
Number.isFinite((entry as PendingSqlServerTraceSession).expiresAt),
);
} catch {
return [];
}
}
function writePendingSessions(sessions: PendingSqlServerTraceSession[]) {
safeLocalStorageSet(PENDING_SESSIONS_STORAGE_KEY, JSON.stringify(sessions));
}
function registerPendingSession(runtime: InternalSqlServerActivityTraceRuntime, sessionName: string) {
activeSessionNames.add(sessionName);
const sessions = readPendingSessions().filter((entry) => entry.sessionName !== sessionName);
sessions.push({
connectionId: runtime.connectionId,
database: runtime.state.selectedDatabase,
sessionName,
expiresAt: Date.now() + (normalizeSqlServerTraceDurationMinutes(runtime.state.durationMinutes) + SQLSERVER_TRACE_STALE_GRACE_MINUTES) * 60_000,
});
writePendingSessions(sessions);
}
function unregisterPendingSession(sessionName: string) {
activeSessionNames.delete(sessionName);
writePendingSessions(readPendingSessions().filter((entry) => entry.sessionName !== sessionName));
}
async function executeControlQuery(connectionId: string, database: string, sql: string, maxRows = 1): Promise<QueryResult> {
return api.executeQuery(connectionId, database || "master", sql, undefined, uuid(), { maxRows });
}
export function cleanupStaleSqlServerTraceSessions(connectionId: string, database = "master"): Promise<number> {
const existing = staleCleanupTasks.get(connectionId);
if (existing) return existing;
const task = (async () => {
const now = Date.now();
const pending = readPendingSessions().filter((entry) => entry.connectionId === connectionId && entry.expiresAt <= now && !activeSessionNames.has(entry.sessionName));
const listResult = await executeControlQuery(connectionId, database, buildListSqlServerTraceSessionsSql(), 500);
const expired = staleSqlServerTraceSessionNames(listResult, now);
const names = [...new Set([...pending.map((entry) => entry.sessionName), ...expired])].filter((name) => !activeSessionNames.has(name));
if (names.length === 0) return 0;
await executeControlQuery(connectionId, database, buildCleanupSqlServerTraceSessionsSql(names));
const cleanedNames = new Set(names);
writePendingSessions(readPendingSessions().filter((entry) => !cleanedNames.has(entry.sessionName)));
return names.length;
})().finally(() => staleCleanupTasks.delete(connectionId));
staleCleanupTasks.set(connectionId, task);
return task;
}
function stopTimers(runtime: InternalSqlServerActivityTraceRuntime) {
if (runtime.pollTimer) clearInterval(runtime.pollTimer);
if (runtime.elapsedTimer) clearInterval(runtime.elapsedTimer);
runtime.pollTimer = undefined;
runtime.elapsedTimer = undefined;
}
function startTimers(runtime: InternalSqlServerActivityTraceRuntime) {
stopTimers(runtime);
runtime.pollTimer = setInterval(() => {
if (runtime.state.status === "running") void pollEvents(runtime);
}, 1000);
runtime.elapsedTimer = setInterval(() => {
if (!runtime.state.startedAt || (runtime.state.status !== "running" && runtime.state.status !== "paused")) return;
runtime.state.elapsedSeconds = Math.max(0, Math.floor((Date.now() - runtime.state.startedAt) / 1000));
if (runtime.state.elapsedSeconds >= normalizeSqlServerTraceDurationMinutes(runtime.state.durationMinutes) * 60) {
void stopRuntime(runtime, "duration");
}
}, 1000);
}
function mergeIncrementalBatch(runtime: InternalSqlServerActivityTraceRuntime, batch: SqlServerTraceEvent[]): SqlServerTraceEvent[] {
const cursor = runtime.cursor;
const additions: SqlServerTraceEvent[] = [];
const countsByTimestamp = new Map<string, Map<string, number>>();
for (const event of batch) {
const timestampCounts = countsByTimestamp.get(event.timestamp) ?? new Map<string, number>();
const occurrence = (timestampCounts.get(event.key) ?? 0) + 1;
timestampCounts.set(event.key, occurrence);
countsByTimestamp.set(event.timestamp, timestampCounts);
if (!cursor || event.timestamp > cursor.timestamp || (event.timestamp === cursor.timestamp && occurrence > (cursor.counts.get(event.key) ?? 0))) {
additions.push({ ...event, key: `${event.key}\u0001${occurrence}` });
}
}
const latestTimestamp = batch[batch.length - 1]?.timestamp;
if (latestTimestamp) runtime.cursor = { timestamp: latestTimestamp, counts: countsByTimestamp.get(latestTimestamp) ?? new Map() };
return additions;
}
async function cancelActiveRead(runtime: InternalSqlServerActivityTraceRuntime) {
const executionId = runtime.activeReadExecutionId;
if (!executionId) return;
runtime.activeReadExecutionId = undefined;
await api.cancelQuery(executionId).catch(() => false);
await runtime.pollPromise?.catch(() => undefined);
}
async function cleanupSession(runtime: InternalSqlServerActivityTraceRuntime) {
const name = runtime.state.sessionName;
if (!name) return;
try {
await executeControlQuery(runtime.connectionId, runtime.state.selectedDatabase, buildStopSqlServerTraceSessionSql(name));
} catch {
// The session may already be stopped or the connection may be recovering.
}
try {
await executeControlQuery(runtime.connectionId, runtime.state.selectedDatabase, buildDropSqlServerTraceSessionSql(name));
runtime.state.sessionName = "";
unregisterPendingSession(name);
} catch (error) {
runtime.state.error = error instanceof Error ? error.message : String(error);
}
}
async function pollEvents(runtime: InternalSqlServerActivityTraceRuntime, options: PollOptions = {}) {
if (!runtime.state.sessionName || runtime.state.polling) return;
if (!options.allowWhileStopping && runtime.state.status !== "running") return;
const generation = runtime.generation;
const executionId = uuid();
runtime.activeReadExecutionId = executionId;
runtime.state.polling = true;
let autoStopReason: SqlServerTraceAutoStopReason | undefined;
const promise = api
.executeQuery(runtime.connectionId, runtime.state.selectedDatabase, buildReadSqlServerTraceEventsSql(runtime.state.sessionName, runtime.cursor?.timestamp), undefined, executionId, { maxRows: SQLSERVER_TRACE_READ_BATCH_SIZE })
.then((result) => {
if (runtime.generation !== generation) return;
const additions = mergeIncrementalBatch(runtime, parseSqlServerTraceEvents(result));
const remaining = Math.max(0, normalizeSqlServerTraceMaxEvents(runtime.state.maxEvents) - runtime.state.events.length);
if (remaining > 0) runtime.state.events.push(...additions.slice(0, remaining));
if (!options.final && runtime.state.events.length >= normalizeSqlServerTraceMaxEvents(runtime.state.maxEvents)) {
autoStopReason = "events";
}
})
.catch(async (error) => {
if (options.final || runtime.generation !== generation || runtime.state.status === "stopping") return;
runtime.state.error = error instanceof Error ? error.message : String(error);
runtime.state.status = "error";
stopTimers(runtime);
await cleanupSession(runtime);
})
.finally(() => {
if (runtime.activeReadExecutionId === executionId) runtime.activeReadExecutionId = undefined;
runtime.state.polling = false;
if (runtime.pollPromise === promise) runtime.pollPromise = undefined;
});
runtime.pollPromise = promise;
await promise;
if (autoStopReason) void stopRuntime(runtime, autoStopReason);
}
async function startRuntime(runtime: InternalSqlServerActivityTraceRuntime) {
const state = runtime.state;
if (!state.selectedDatabase || state.status === "running" || state.status === "paused" || state.status === "starting" || state.status === "stopping") return;
state.error = "";
state.capabilityProblem = null;
state.capabilityVersion = "";
state.missingCapabilities = [];
state.status = "starting";
state.maxEvents = normalizeSqlServerTraceMaxEvents(state.maxEvents);
state.durationMinutes = normalizeSqlServerTraceDurationMinutes(state.durationMinutes);
state.events = [];
state.selectedEvent = null;
state.elapsedSeconds = 0;
runtime.cursor = undefined;
runtime.generation += 1;
const generation = runtime.generation;
try {
await cleanupStaleSqlServerTraceSessions(runtime.connectionId, state.selectedDatabase);
const capabilities = parseSqlServerTraceCapabilities(await executeControlQuery(runtime.connectionId, state.selectedDatabase, buildSqlServerTraceCapabilitiesSql(state.selectedDatabase)));
const problem = sqlServerTraceCapabilityProblem(capabilities, state.includeStatements);
if (problem) {
state.capabilityProblem = problem;
state.capabilityVersion = capabilities.productVersion;
state.missingCapabilities = missingSqlServerTraceCapabilities(capabilities, state.includeStatements);
throw new Error(problem);
}
const name = buildSqlServerTraceSessionName(Date.now() + (state.durationMinutes + SQLSERVER_TRACE_STALE_GRACE_MINUTES) * 60_000);
state.sessionName = name;
registerPendingSession(runtime, name);
await executeControlQuery(
runtime.connectionId,
state.selectedDatabase,
buildCreateSqlServerTraceSessionSql({
sessionName: name,
databaseId: capabilities.databaseId,
maxEvents: state.maxEvents,
includeStatements: state.includeStatements,
}),
);
if (runtime.generation !== generation) {
await cleanupSession(runtime);
return;
}
state.startedAt = Date.now();
state.status = "running";
startTimers(runtime);
void pollEvents(runtime);
} catch (error) {
if (!state.capabilityProblem) state.error = error instanceof Error ? error.message : String(error);
state.status = "error";
stopTimers(runtime);
await cleanupSession(runtime);
}
}
async function stopRuntime(runtime: InternalSqlServerActivityTraceRuntime, reason?: SqlServerTraceAutoStopReason, final = true) {
if (runtime.stopPromise) return runtime.stopPromise;
const promise = (async () => {
if (!runtime.state.sessionName && runtime.state.status !== "starting") return;
runtime.state.status = "stopping";
stopTimers(runtime);
runtime.generation += 1;
await runtime.startPromise?.catch(() => undefined);
await cancelActiveRead(runtime);
if (final && runtime.state.sessionName) await pollEvents(runtime, { final: true, allowWhileStopping: true });
await cleanupSession(runtime);
runtime.state.status = runtime.state.sessionName ? "error" : "stopped";
if (reason && !runtime.state.sessionName) {
runtime.state.autoStopReason = reason;
runtime.state.autoStopRevision += 1;
}
})().finally(() => {
if (runtime.stopPromise === promise) runtime.stopPromise = undefined;
});
runtime.stopPromise = promise;
await promise;
}
export function getSqlServerActivityTraceRuntime(tabId: string, connectionId: string, database = ""): SqlServerActivityTraceRuntime {
const existing = runtimes.get(tabId);
if (existing) return existing;
const runtime: InternalSqlServerActivityTraceRuntime = {
tabId,
connectionId,
generation: 0,
state: reactive<SqlServerActivityTraceState>({
status: "idle",
selectedDatabase: database,
includeStatements: false,
maxEvents: SQLSERVER_TRACE_DEFAULT_MAX_EVENTS,
durationMinutes: SQLSERVER_TRACE_DEFAULT_DURATION_MINUTES,
events: [],
selectedEvent: null,
sqlFilter: "",
loginFilter: "",
clientFilter: "",
sessionFilter: "",
error: "",
sessionName: "",
startedAt: null,
elapsedSeconds: 0,
polling: false,
autoStopReason: null,
autoStopRevision: 0,
capabilityProblem: null,
capabilityVersion: "",
missingCapabilities: [],
}),
start: () => {
if (runtime.startPromise) return runtime.startPromise;
const promise = startRuntime(runtime).finally(() => {
if (runtime.startPromise === promise) runtime.startPromise = undefined;
});
runtime.startPromise = promise;
return promise;
},
pause: () => {
if (runtime.state.status !== "running") return;
runtime.state.status = "paused";
if (runtime.pollTimer) clearInterval(runtime.pollTimer);
runtime.pollTimer = undefined;
},
resume: async () => {
if (runtime.state.status !== "paused") return;
runtime.state.status = "running";
startTimers(runtime);
await pollEvents(runtime);
},
stop: (reason) => stopRuntime(runtime, reason),
clearEvents: () => {
runtime.state.events = [];
runtime.state.selectedEvent = null;
},
};
runtimes.set(tabId, runtime);
return runtime;
}
export async function disposeSqlServerActivityTrace(tabId: string): Promise<void> {
const runtime = runtimes.get(tabId);
if (!runtime) return;
runtime.generation += 1;
stopTimers(runtime);
await cancelActiveRead(runtime);
await runtime.startPromise?.catch(() => undefined);
if (runtime.state.sessionName) await stopRuntime(runtime, undefined, false);
if (runtimes.get(tabId) === runtime) runtimes.delete(tabId);
}
export async function disposeAllSqlServerActivityTraces(): Promise<void> {
await Promise.all([...runtimes.keys()].map(disposeSqlServerActivityTrace));
}
export async function disposeSqlServerActivityTracesForConnection(connectionId: string, database?: string): Promise<void> {
const tabIds = [...runtimes.values()].filter((runtime) => runtime.connectionId === connectionId && (!database || runtime.state.selectedDatabase === database)).map((runtime) => runtime.tabId);
await Promise.all(tabIds.map(disposeSqlServerActivityTrace));
}
export function activeSqlServerActivityTraceRuntimeCount(): number {
return runtimes.size;
}
export function hasSqlServerActivityTraceForConnection(connectionId: string, database?: string): boolean {
return [...runtimes.values()].some((runtime) => runtime.connectionId === connectionId && (!database || runtime.state.selectedDatabase === database));
}
export function sqlServerTracePendingSessionLifetimeMs(): number {
return (SQLSERVER_TRACE_MAX_DURATION_MINUTES + SQLSERVER_TRACE_STALE_GRACE_MINUTES) * 60_000;
}