Published on

Better Tagging of EF Core Queries

One of the quickest ways to earn the ire of a DBA is to shrug your shoulders, and tell them you don't know what code created the query that is slowing down their server. Sure, with a little hunting and knowledge of the code base you can generally spot the offending query, but wouldn't it be nice if the query the DBA is looking at had more information to track it down?

This is where query tagging comes in. Query tags let you add a comment to the command EF sends to your database provider. Because this tag is a comment included as part of the command, it will be stored with the statement in your common DBA tools like SQL Query Store or when viewing them via Extended Events.

With EF Core 2.2 Microsoft added the TagWith extension method. This allows us to write a query such as

var result = await bloggingContext.Blogs
    .Where(i => i.Url.StartsWith("http://example.com"))
    .TagWith("Looking for example.com")
    .FirstOrDefaultAsync();

Now when you execute your code the following statement, you'll see a comment included with the command

-- Looking for example.com

SELECT "b"."BlogId", "b"."Url"
FROM "Blogs" AS "b"
WHERE "b"."Url" IS NOT NULL AND ("b"."Url" LIKE 'http://%')
LIMIT 1

Now when an irate DBA sees a misbehaving query they can pass it along hopefully keeping the comment. Now a little find in files and we should be able to go right there.

But what if we wanted to make it even easier on us?

Using caller information attributes we can automatically pull the method, filename, and line number. Given this we can create our own TagWith that accepts these parameters and automatically generates a comment with the source location of whoever is calling the LINQ query. Our extension method will look similar to this.

public static IQueryable<T> TagWithSource<T>(
    this IQueryable<T> queryable,
    [CallerLineNumber] int lineNumber = 0,
    [CallerFilePath] string filePath = "",
    [CallerMemberName] string memberName = "")
{
    return queryable.TagWith($"{memberName}  - {filePath}:{lineNumber}");
}

Additionally, TagWith can be chained. EF will allow you to call it multiple times and it'll generate a new comment for each call

var result = await bloggingContext.Blogs
    .Where(i => i.Url.StartsWith("http://example.com"))
    .TagWith("Looking for example.com")
    .TagWithSource()
    .FirstOrDefaultAsync();

This generated the following command

-- Looking for example.com

-- Test1  - R:\Projects\thirty25\ef-core-tagging\tests\EfCoreTagging.Tests\UnitTest1.cs:45

SELECT "b"."BlogId", "b"."Url"
FROM "Blogs" AS "b"
WHERE "b"."Url" IS NOT NULL AND ("b"."Url" LIKE 'http://%')
LIMIT 1

Because this pattern is pretty common, I'll also typically create an overload for our TagWithSource that takes in a string

public static IQueryable<T> TagWithSource<T>(this IQueryable<T> queryable,
    string tag,
    [CallerLineNumber] int lineNumber = 0,
    [CallerFilePath] string filePath = "",
    [CallerMemberName] string memberName = "")
{
    return queryable.TagWith($"{tag}{Environment.NewLine}{memberName}  - {filePath}:{lineNumber}");
}

This will produce similar output as above, but slightly cleaner without the line-break between the two tag statements

-- Looking for example.com
-- Test1  - R:\Projects\thirty25\ef-core-tagging\tests\EfCoreTagging.Tests\UnitTest1.cs:45

SELECT "b"."BlogId", "b"."Url"
FROM "Blogs" AS "b"
WHERE "b"."Url" IS NOT NULL AND ("b"."Url" LIKE 'http://%')
LIMIT 1

Limitations

The biggest limitation is that you'll only get the immediate caller of your queries. If you have this buried in a super abstracted repository there's a good chance every comment will be the same comment pointing to something like the Get - Repository.cs:49. In this case you might want to look at parsing the StackTrace to move up the stack and include all the user code in the path with your tag. I know that we've all been taught to avoid accessing the stack trace directly because "it's slow", but honestly grabbing the stacktrace and looping through all the frames with file names and line numbers is around 50 microseconds on my dev machine. We can make a database query that will be measured in, at best, milliseconds. Personally, in the systems I've worked with this is an acceptable perf loss for the added benefit, but obviously don't implement it blindly if this code is part of an extremely high traffic site.