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 }]);
});
});