Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 13 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
[![.NET Version](https://img.shields.io/badge/.NET-10.0-blue)](https://dotnet.microsoft.com/)
[![VS Code Marketplace](https://img.shields.io/visual-studio-marketplace/v/brandochn.light-query-profiler?label=VS%20Code%20Marketplace)](https://marketplace.visualstudio.com/items?itemName=brandochn.light-query-profiler)
[![VS Code Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/brandochn.light-query-profiler)](https://marketplace.visualstudio.com/items?itemName=brandochn.light-query-profiler)
[![Open VSX Registry](https://img.shields.io/open-vsx/v/brandochn/light-query-profiler?label=Open%20VSX%20Registry)](https://open-vsx.org/extension/brandochn/light-query-profiler)

> A lightweight query profiler for SQL Server and Azure SQL Database — available as a desktop application and as a [VS Code extension](https://marketplace.visualstudio.com/items?itemName=brandochn.light-query-profiler).
> A lightweight query profiler for SQL Server and Azure SQL Database — available as a desktop application and as a VS Code extension on the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=brandochn.light-query-profiler) and the [Open VSX Registry](https://open-vsx.org/extension/brandochn/light-query-profiler).

## Table of Contents

Expand Down Expand Up @@ -55,13 +56,15 @@ without re-entering credentials.

## VS Code Extension

Light Query Profiler is available on the **Visual Studio Code Marketplace**:
Light Query Profiler is available on the **Visual Studio Code Marketplace** and the **Open VSX Registry**:

**[Install from the VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=brandochn.light-query-profiler)**

**[Install from the Open VSX Registry](https://open-vsx.org/extension/brandochn/light-query-profiler)**

### Getting Started with the VS Code Extension

1. Install the extension from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=brandochn.light-query-profiler)
1. Install the extension from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=brandochn.light-query-profiler) or the [Open VSX Registry](https://open-vsx.org/extension/brandochn/light-query-profiler)
2. Open the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`)
3. Run **Light Query Profiler: Show SQL Profiler**
4. Enter your connection details:
Expand Down Expand Up @@ -92,11 +95,12 @@ The exported JSON format is compatible between the VS Code extension and the des

## Authentication Modes

| Mode | Description |
| ------------------------- | -------------------------------------------------------- |
| Windows Authentication | Uses the current Windows user credentials (Windows only) |
| SQL Server Authentication | Username and password |
| Azure Active Directory | Azure AD authentication for Azure SQL Database |
| Mode | Description |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| Windows Authentication | Uses the current Windows user credentials (Windows only) |
| SQL Server Authentication | Username and password |
| Azure Active Directory | Azure AD authentication for Azure SQL Database |
| Connection String | Provide a full ADO.NET connection string. Supports any valid SQL Server or Azure SQL Database connection string. |

---

Expand All @@ -119,7 +123,7 @@ The project runs on **Windows**, **Linux**, and **macOS**, provided .NET 10 is i

This project is actively under development. Stable releases are already available for both the desktop application and the VS Code extension:

- **VS Code Extension** — available on the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=brandochn.light-query-profiler)
- **VS Code Extension** — available on the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=brandochn.light-query-profiler) and the [Open VSX Registry](https://open-vsx.org/extension/brandochn/light-query-profiler)
- **Desktop App** — available on the [GitHub Releases](https://github.com/brandochn/LightQueryProfiler/releases) page

Contributions are always welcome — feel free to open an issue or submit a pull request.
Expand Down
81 changes: 68 additions & 13 deletions src/LightQueryProfiler.JsonRpc/JsonRpcServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using LightQueryProfiler.Shared.Repositories.Interfaces;
using LightQueryProfiler.Shared.Services;
using LightQueryProfiler.Shared.Services.Interfaces;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using StreamJsonRpc;

Expand All @@ -21,6 +22,7 @@ public class JsonRpcServer
private readonly Dictionary<string, IProfilerService> _activeSessions;
private readonly Dictionary<string, IApplicationDbContext> _activeContexts;
private readonly IConnectionRepository _connectionRepository;
private readonly IDatabaseEngineDetector _engineDetector;

public JsonRpcServer(ILogger<JsonRpcServer> logger)
{
Expand All @@ -31,20 +33,22 @@ public JsonRpcServer(ILogger<JsonRpcServer> logger)
_connectionRepository = new ConnectionRepository(
new SqliteContext(),
new AesGcmPasswordProtectionService());
_engineDetector = new DatabaseEngineDetector();
}

/// <summary>
/// Internal constructor for unit testing — allows injection of a mock repository
/// without requiring a real SQLite database on disk.
/// </summary>
internal JsonRpcServer(ILogger<JsonRpcServer> logger, IConnectionRepository connectionRepository)
internal JsonRpcServer(ILogger<JsonRpcServer> logger, IConnectionRepository connectionRepository, IDatabaseEngineDetector? engineDetector = null)
{
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(connectionRepository);
_logger = logger;
_activeSessions = new Dictionary<string, IProfilerService>();
_activeContexts = new Dictionary<string, IApplicationDbContext>();
_connectionRepository = connectionRepository;
_engineDetector = engineDetector ?? new DatabaseEngineDetector();
}

/// <summary>
Expand All @@ -71,7 +75,8 @@ public async Task StartProfilingAsync(StartProfilingRequest request, Cancellatio
throw new ArgumentException("ConnectionString cannot be null or empty", nameof(request));
}

if (!Enum.IsDefined(typeof(DatabaseEngineType), request.EngineType))
// Allow 0 (auto-detect for ConnectionString mode) or a defined DatabaseEngineType value.
if (request.EngineType != 0 && !Enum.IsDefined(typeof(DatabaseEngineType), request.EngineType))
{
throw new ArgumentException($"Invalid EngineType: {request.EngineType}", nameof(request));
}
Expand All @@ -83,31 +88,48 @@ public async Task StartProfilingAsync(StartProfilingRequest request, Cancellatio
request.SessionName, request.EngineType);
}


try
{
// Create context and services for this session
var dbContext = new ApplicationDbContext(request.ConnectionString);
// Guarantee the profiler's own SQL connections are tagged with "LightQueryProfiler"
// as the Application Name. ProfilerService.IsProfilerGeneratedEvent uses
// client_app_name to exclude the profiler's XEvent management queries (create session,
// read ring buffer, etc.) from the captured event stream. In standard auth modes this
// is handled by toConnectionString() on the TypeScript client side; for ConnectionString
// mode we normalise here at the server level so all modes are covered consistently.
var csBuilder = new SqlConnectionStringBuilder(request.ConnectionString);
csBuilder.ApplicationName = "LightQueryProfiler";
var dbContext = new ApplicationDbContext(csBuilder.ConnectionString);

DatabaseEngineType effectiveEngineType;
if (request.EngineType == 0)
{
effectiveEngineType = await _engineDetector.DetectEngineTypeAsync(dbContext, cancellationToken)
.ConfigureAwait(false);
}
else
{
effectiveEngineType = (DatabaseEngineType)request.EngineType;
}

var xEventRepository = new XEventRepository(dbContext);
xEventRepository.SetEngineType((DatabaseEngineType)request.EngineType);
xEventRepository.SetEngineType(effectiveEngineType);

var xEventService = new XEventService();
var profilerService = new ProfilerService(xEventRepository, xEventService);

// Create template based on engine type
var template = ProfilerSessionTemplateFactory.CreateTemplate((DatabaseEngineType)request.EngineType);
var template = ProfilerSessionTemplateFactory.CreateTemplate(effectiveEngineType);

// Start profiling
await Task.Run(() => profilerService.StartProfiling(request.SessionName, template), cancellationToken)
.ConfigureAwait(false);

// Store for later use
_activeSessions[request.SessionName] = profilerService;
_activeContexts[request.SessionName] = dbContext;

if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation("Profiling session started successfully: {SessionName}", request.SessionName);
_logger.LogInformation(
"Profiling session started successfully: {SessionName} with engine type: {EngineType}",
request.SessionName, effectiveEngineType);
}
}
catch (Exception ex)
Expand Down Expand Up @@ -307,6 +329,7 @@ public async Task<List<RecentConnectionDto>> GetRecentConnectionsAsync(
IntegratedSecurity = c.IntegratedSecurity,
EngineType = c.EngineType.HasValue ? (int)c.EngineType.Value : null,
AuthenticationMode = (int)c.AuthenticationMode,
ConnectionString = c.ConnectionString,
})
.ToList();
}
Expand All @@ -328,7 +351,41 @@ public async Task SaveRecentConnectionAsync(
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
cancellationToken.ThrowIfCancellationRequested();

// ── ConnectionString mode — MUST come before DataSource/InitialCatalog guards ──
if (request.AuthenticationMode.HasValue
&& request.AuthenticationMode.Value == (int)AuthenticationMode.ConnectionString)
{
ArgumentException.ThrowIfNullOrWhiteSpace(request.ConnectionString, nameof(request));

var builder = new SqlConnectionStringBuilder(request.ConnectionString);
var connection = new Connection(
id: 0,
initialCatalog: builder.InitialCatalog,
creationDate: DateTime.UtcNow,
dataSource: builder.DataSource,
integratedSecurity: builder.IntegratedSecurity,
password: null,
userId: string.IsNullOrEmpty(builder.UserID) ? null : builder.UserID,
engineType: null,
authenticationMode: AuthenticationMode.ConnectionString,
connectionString: request.ConnectionString);

await _connectionRepository.UpsertAsync(connection).ConfigureAwait(false);

if (_logger.IsEnabled(LogLevel.Information))
{
_logger.LogInformation(
"Recent connection saved (ConnString mode): DataSource={DataSource}, InitialCatalog={InitialCatalog}",
builder.DataSource,
builder.InitialCatalog);
}

return;
}

// ── Standard mode guards (unchanged) ──────────────────────────────────
if (string.IsNullOrWhiteSpace(request.DataSource))
{
throw new ArgumentException("DataSource cannot be null or empty", nameof(request));
Expand All @@ -339,8 +396,6 @@ public async Task SaveRecentConnectionAsync(
throw new ArgumentException("InitialCatalog cannot be null or empty", nameof(request));
}

cancellationToken.ThrowIfCancellationRequested();

try
{
var connection = new Connection(
Expand Down
9 changes: 9 additions & 0 deletions src/LightQueryProfiler.JsonRpc/Models/RecentConnectionDto.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,14 @@ public record RecentConnectionDto
public bool IntegratedSecurity { get; init; }
public int? EngineType { get; init; }
public int? AuthenticationMode { get; init; }

/// <summary>
/// Gets the plain-text ADO.NET connection string, decrypted by the repository layer before mapping.
/// </summary>
/// <remarks>
/// Only populated when <c>AuthenticationMode</c> equals 3 (<c>ConnectionString</c> mode).
/// Never log this value — it may contain credentials.
/// </remarks>
public string? ConnectionString { get; init; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,14 @@ public record SaveRecentConnectionRequest
public bool IntegratedSecurity { get; init; }
public int? EngineType { get; init; }
public int? AuthenticationMode { get; init; }

/// <summary>
/// Gets the plain-text ADO.NET connection string provided by the user.
/// </summary>
/// <remarks>
/// Only set when <c>AuthenticationMode</c> equals 3 (<c>ConnectionString</c> mode).
/// The repository layer encrypts this value before storage. Never log this value.
/// </remarks>
public string? ConnectionString { get; init; }
}
}
19 changes: 18 additions & 1 deletion src/LightQueryProfiler.Shared/Data/SqliteContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ Password NVARCHAR(100) NULL,
IntegratedSecurity INTEGER NULL,
CreationDate Date,
EngineType INTEGER NULL,
AuthenticationMode INTEGER NULL
AuthenticationMode INTEGER NULL,
ConnectionString NVARCHAR(2000) NULL
)";

SqliteCommand createTable = new(tableCommand, db);
Expand Down Expand Up @@ -74,6 +75,22 @@ FROM pragma_table_info('Connections')
SqliteCommand alterTableAuthMode = new(alterTableAuthModeCommand, db);
await alterTableAuthMode.ExecuteNonQueryAsync();
}

// Migration: Add ConnectionString column if it doesn't exist
const string addConnStringColumnCheck = @"
SELECT COUNT(*) as ColumnExists
FROM pragma_table_info('Connections')
WHERE name='ConnectionString'";

SqliteCommand checkConnStringColumn = new(addConnStringColumnCheck, db);
var connStringResult = await checkConnStringColumn.ExecuteScalarAsync();

if (connStringResult != null && Convert.ToInt32(connStringResult) == 0)
{
const string alterTableConnString = "ALTER TABLE Connections ADD COLUMN ConnectionString NVARCHAR(2000) NULL";
SqliteCommand alterTableCmd = new(alterTableConnString, db);
await alterTableCmd.ExecuteNonQueryAsync();
}
}
}
}
1 change: 1 addition & 0 deletions src/LightQueryProfiler.Shared/Enums/AuthenticationMode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ public enum AuthenticationMode
WindowsAuth = 0,
SQLServerAuth = 1,
AzureSQLDatabase = 2,
ConnectionString = 3,
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ public static string GetString(this AuthenticationMode am)
case AuthenticationMode.AzureSQLDatabase:
return "Azure SQL Database";

case AuthenticationMode.ConnectionString:
return "Connection String";

default:
return string.Empty;
}
Expand Down
24 changes: 23 additions & 1 deletion src/LightQueryProfiler.Shared/Models/Connection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,20 @@ namespace LightQueryProfiler.Shared.Models
{
public class Connection
{
public Connection(int id, string initialCatalog, DateTime creationDate, string dataSource, bool integratedSecurity, string? password, string? userId, DatabaseEngineType? engineType = null, AuthenticationMode authenticationMode = AuthenticationMode.WindowsAuth)
/// <summary>
/// Initializes a new instance of <see cref="Connection"/>.
/// </summary>
/// <param name="id">Primary key (0 for new entities).</param>
/// <param name="initialCatalog">Database name.</param>
/// <param name="creationDate">When the connection was last used.</param>
/// <param name="dataSource">Server address or named instance.</param>
/// <param name="integratedSecurity">True when using Windows Authentication.</param>
/// <param name="password">Plain-text password; encrypted by the repository layer before storage.</param>
/// <param name="userId">SQL Server or Azure AD login name.</param>
/// <param name="engineType">Detected or specified engine type. <see langword="null"/> when using <see cref="AuthenticationMode.ConnectionString"/> (detected at start-profiling time).</param>
/// <param name="authenticationMode">Authentication method used.</param>
/// <param name="connectionString">Raw ADO.NET connection string. Only set when <paramref name="authenticationMode"/> is <see cref="AuthenticationMode.ConnectionString"/>. Plain-text — the repository layer decrypts it before passing here. Never log this value.</param>
public Connection(int id, string initialCatalog, DateTime creationDate, string dataSource, bool integratedSecurity, string? password, string? userId, DatabaseEngineType? engineType = null, AuthenticationMode authenticationMode = AuthenticationMode.WindowsAuth, string? connectionString = null)
{
Id = id;
InitialCatalog = initialCatalog;
Expand All @@ -15,6 +28,7 @@ public Connection(int id, string initialCatalog, DateTime creationDate, string d
UserId = userId;
EngineType = engineType;
AuthenticationMode = authenticationMode;
ConnectionString = connectionString;
}

public int Id { get; }
Expand All @@ -37,5 +51,13 @@ public Connection(int id, string initialCatalog, DateTime creationDate, string d
/// Gets the authentication mode used for this connection
/// </summary>
public AuthenticationMode AuthenticationMode { get; }

/// <summary>
/// Gets the raw ADO.NET connection string entered by the user.
/// Only populated when <see cref="AuthenticationMode"/> is <see cref="AuthenticationMode.ConnectionString"/>.
/// Value is plain-text — the repository layer decrypts it before setting this property.
/// </summary>
/// <remarks>Never log this value — it may contain credentials.</remarks>
public string? ConnectionString { get; }
}
}
Loading
Loading