Reading Structured Logs with Seq: Docker Setup, Queries, Signals and Alerts
We run Seq in Docker with a password and a separate ingestion port, and send logs through Serilog and OTLP with an Ingest-only API key. Filters, group by and percentile queries, logs and spans on the same TraceId, signals, alerts and signal-based retention, all with real results, including why 8 failures became 16 events and the ERROR level trap with OTLP.
Version note: The code in this post was built and run with .NET 10, Seq 2026.1, Serilog.AspNetCore 10.0, Serilog.Sinks.Seq 9.1 and OpenTelemetry .NET 1.20.
Guess First
We send 210 requests to the Bookstore API (Application Programming Interface): 120 book lookups, 60 orders and 30 searches. 8 of the orders return 500 because of the payment gateway. In Seq you ask "which endpoint produced the most errors?" and see 16 for /api/orders. How did 8 failed requests turn into 16 errors? Second question: when you build the same query for "which endpoint is the slowest?" grouped by RequestPath, why do you get more than a hundred rows instead of three? Third: why does the filter @Level = 'Error' return 0 results for error logs that arrive through OpenTelemetry?
All three answers are in this post, with real query results.
Running Seq in Docker
Seq is a log server from Datalust built for structured logs: it stores events together with their properties and lets you run filters and SQL (Structured Query Language)-like queries over them. In the earlier posts of this series we used Serilog's Seq sink; in this one we learn to read Seq itself. On a development machine the easiest way is Docker:
docker run --name lgwork-seq-05 -d \
-e ACCEPT_EULA=Y \
-e SEQ_FIRSTRUN_ADMINPASSWORD="$SEQ_ADMIN_PASSWORD" \
-v "$PWD/seqdata:/data" \
-p 17498:80 -p 17499:5341 \
datalust/seq:2026.1
ACCEPT_EULA=Y is required; if the EULA (End User License Agreement) isn't accepted, the container doesn't start. There are two HTTP (HyperText Transfer Protocol) ports inside the container: 80 serves both the web UI and the API, 5341 is ingestion only. Exposing them separately means that in production you can open only 5341 to the applications that send logs and only the UI to people. The /data volume holds the events and the settings; if you don't mount it, all logs go away with the container. I put the password in an environment variable instead of on the command line, so it doesn't end up in the shell history. The container's own log:
[02:27:01 INF] Seq 2026.1.17526 running on OS Ubuntu 24.04.5 LTS
[02:27:01 INF] Seq listening on ["http://localhost/", "https://localhost/", "http://localhost:5341/", "https://localhost:45341/"]
[02:27:01 INF] Opening event store at /data/Stream/stream.flare
[02:27:01 INF] Opening metastore /data/Documents/metastore.flare
[02:27:01 INF] Initializing a new metastore
[02:27:01 INF] Enabling username/password authentication, and using the supplied default admin password
[02:27:02 INF] Creating default workspace for user-admin
[02:27:02 INF] Storage subsystem available
[02:27:02 INF] Ingestion enabled
Look familiar? Seq writes its own log in Serilog's console format too. On first login the admin user has to change the password; when I tried to log in through the API, the answer was {"Error":"A password change is required.","MustChangePassword":true}.
Sending Logs with an API Key: Serilog.Sinks.Seq
Applications should send logs to Seq not with the admin password but with an API key that has only the Ingest permission. You can create the key in the UI (Settings › API Keys) or with seqcli. I ran seqcli as a container too:
docker run --rm -i datalust/seqcli:2026.1 apikey create \
-t Bookstore.Api --permissions=Ingest \
--property=Application=Bookstore.Api --minimum-level=Information \
-s http://host.docker.internal:17498 \
--connect-username=admin --connect-password-stdin
Two of these parameters are useful details. --property adds Application = 'Bookstore.Api' on the server side to every event that arrives with this key; even if the app doesn't send it, you know which service an event came from. --minimum-level rejects anything below Information on the server; if someone switches on Debug by mistake, Seq's disk doesn't fill up. The command's only output is the key itself; I put it in an environment variable and pass it to the app (Seq__ApiKey).
The application side is the same setup as in this series' "Serilog in Depth" post, with a Seq sink added:
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
string seqUrl = builder.Configuration["Seq:ServerUrl"]!;
string? apiKey = builder.Configuration["Seq:ApiKey"];
builder.Services.AddSerilog((services, lc) => lc
.ReadFrom.Configuration(builder.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console()
.WriteTo.Seq(seqUrl, apiKey: apiKey));
In appsettings.json, Seq:ServerUrl points at the ingestion port (http://localhost:17499); the API key is left empty and comes from the environment variable. I also added a property to request logging; you'll see why once we get to the queries:
app.UseSerilogRequestLogging(options =>
{
// route template instead of the path with the id: /api/books/{id:int}
options.EnrichDiagnosticContext = (diagnostics, httpContext) =>
diagnostics.Set("RoutePattern", (httpContext.GetEndpoint() as RouteEndpoint)?.RoutePattern.RawText);
});
The Alternative: Sending to Seq over OTLP
Seq also accepts OTLP (OpenTelemetry Protocol). If you don't use Serilog, you can send logs with just Microsoft.Extensions.Logging and the OpenTelemetry exporter. The endpoint for logs is /ingest/otlp/v1/logs, for traces /ingest/otlp/v1/traces; the API key goes in the X-Seq-ApiKey header:
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.AddOtlpExporter(otlp =>
{
otlp.Endpoint = new Uri($"{seqUrl}/ingest/otlp/v1/logs");
otlp.Protocol = OtlpExportProtocol.HttpProtobuf;
otlp.Headers = $"X-Seq-ApiKey={apiKey}";
});
});
builder.Services.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService("Bookstore.Api"))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation()
.AddOtlpExporter(otlp =>
{
otlp.Endpoint = new Uri($"{seqUrl}/ingest/otlp/v1/traces");
otlp.Protocol = OtlpExportProtocol.HttpProtobuf;
otlp.Headers = $"X-Seq-ApiKey={apiKey}";
}));
I chose HttpProtobuf as the protocol because, according to Seq's documentation, ingestion over gRPC (gRPC Remote Procedure Calls) only works over HTTPS (HTTP Secure). I kept tracing on in both modes; we'll need it for the trace view. The difference between the two paths shows up in the queries; I'll show it in the pitfalls section.
For sample traffic, a small shell script sends the API 120 book requests (20 of them 404), 60 orders (6 rejected with 409 because of stock, 8 failing with 500 because of the payment gateway) and 30 search requests. All the results below are from this single run.
Searching with Filters
The search box in the Seq UI expects a filter expression. Properties are written by name, Seq's own fields with an @ prefix: @Level, @Message, @MessageTemplate, @Exception, @Timestamp, @TraceId. You can run the same expressions through the API too; that's how I collected the results here. The filters I tried in this run and the number of matching events:
CustomerId = 41 -> 1 ("Order 01a1b8fe-9281-7db5-8ad1-ae61216ffec0 placed for customer 41: 1 x 978-0135957059")
CustomerId = 42 -> 0
RequestPath like '/api/books/%' and StatusCode = 404 -> 20
@Message like '%timed out%' -> 0
@Exception like '%timed out%' -> 16
@Exception like '%PaymentGatewayException%' -> 16
This short list holds three lessons. First, CustomerId = 41 is the payoff of structured logging: not a text search but a property comparison. Second, CustomerId = 42 returns nothing, because customer 42's order got a 500 and none of that request's error logs has a CustomerId. In the first post of this series we said "solve context with a scope or a trace id, not by logging the exception again"; this is what that gap looks like in Seq. Third, the text "timed out" is in @Exception, not in @Message; the exception handler's message only says "An unhandled exception has occurred while executing the request."
The way to save a filter is a signal; we'll get to it shortly.
SQL-Style Queries: group by, count and percentile
Besides filters, Seq lets you write queries that start with select ... from stream. The classic question from the plan: "which endpoint produced the most errors in the last hour?"
select count(*) as Errors from stream
where @Level = 'Error' and @Timestamp > Now() - 1h
group by RequestPath order by Errors desc limit 5
{"Columns":["RequestPath","Errors"],"Rows":[["/api/orders",16]]}
Here's the answer to the first question: 8 failed requests, 16 Error events. Every failed request is logged twice at Error level: once as the exception handler's "An unhandled exception" line, and once as the summary line UseSerilogRequestLogging writes for the 500 response. Neither is wrong, but if you're asking for the number of failures, count requests, not events:
select count(*) as Errors, count(distinct(RequestId)) as Requests from stream
where @Level = 'Error' group by RequestPath
{"Columns":["RequestPath","Errors","Requests"],"Rows":[["/api/orders",16,8]]}
RequestId comes from the scope ASP.NET Core opens for every request; Serilog turns that scope into properties. Knowing this difference is critical when you set up an alert; otherwise you tune the threshold against twice the truth.
Now the second question. If you build "how slow is each endpoint?" with RequestPath:
select count(*) as Requests from stream where Has(StatusCode) group by RequestPath limit 4
{"Columns":["RequestPath","Requests"],"Rows":[["/api/books/69",1],["/api/books/67",1],["/api/books/74",1],["/api/books/56",1]]}
Without the limit there would be a separate row for every book id; the query splits 120 book requests into 120 groups. RequestPath is the actual path, id included. What grouping needs is the route template, and the RoutePattern I added to request logging carries exactly that:
select count(*) as Requests, percentile(Elapsed, 95) as P95 from stream
where Has(StatusCode) group by RoutePattern order by P95 desc
{"Columns":["RoutePattern","Requests","P95"],"Rows":[["/api/search",30,907.122059579768],["/api/orders",60,0.10752581343173],["/api/books/{id:int}",120,0.03370655317554697]]}
Three rows, a readable result: the search endpoint's 95th percentile duration is 907 milliseconds, the others are below a millisecond. The status code distribution with the same property:
select count(*) as Requests from stream where Has(StatusCode) group by RoutePattern, StatusCode
{"Columns":["RoutePattern","StatusCode","Requests"],"Rows":[["/api/search",200,30],["/api/books/{id:int}",404,20],["/api/orders",500,8],["/api/books/{id:int}",200,100],["/api/orders",409,6],["/api/orders",201,46]]}
You can run these queries in the UI and pin the result as a chart on a dashboard; adding group by time(1m) turns the result into a time series. Just remember that numbers like these are really metrics. Pulling numbers out of logs in Seq is very useful during an incident, but the right place for a continuously watched p95 duration is a metrics stack; we leave that to the upcoming DevOps › Observability series.
The Trace View: Logs and Spans on the Same TraceId
Serilog 4 adds the current Activity's trace and span ids to every event on its own, and the Seq sink sends them. Since I also export tracing to Seq over OTLP, the same Seq has spans as well. A query with the trace id of one 500:
select @Level, @SpanKind, @Message from stream where @TraceId = '4eec0e6c3a26ce56d864762d1a553571'
{"Columns":["@Level","@SpanKind","@Message"],"Rows":[["Error",null,"HTTP POST /api/orders responded 500 in 0.0547 ms"],["Error",null,"An unhandled exception has occurred while executing the request."],["ERROR","Server","POST /api/orders"]]}
The first two rows are log events from Serilog, the third is the server span produced by the ASP.NET Core instrumentation. When you open one of these events in the UI, a "Trace" menu appears and shows the request's span tree with durations. The span's properties carry OpenTelemetry semantic convention names (http.request.method, http.response.status_code, url.path); resource attributes such as the service name and the SDK (Software Development Kit) version sit under @Resource. We'll build how the trace id travels across services and queues in the next post, "Correlation IDs and Trace Context".
Signals, Alerts and Retention
A signal is a saved filter. You can type a filter in the UI and choose "Save as signal"; I sent the same thing to the API with this body:
{
"Title": "Bookstore.Api errors",
"Filters": [
{ "Filter": "Application = 'Bookstore.Api' and @Level = 'Error'" }
]
}
Signals sit one click away in the UI's right panel, and several can be selected and combined. Because Seq indexes signals, turning a frequently used filter into a signal also makes queries faster.
An alert is a query that runs periodically. The rule "more than 5 5xx responses for a route in the last 5 minutes":
{
"Title": "Bookstore.Api 5xx spike",
"Where": "Application = 'Bookstore.Api' and StatusCode >= 500",
"GroupBy": [ { "Value": "RoutePattern" } ],
"TimeGrouping": "00:05:00",
"Select": [ { "Value": "count(*)", "Label": "Failures" } ],
"Having": "Failures > 5",
"NotificationLevel": "Error",
"SuppressionTime": "00:15:00"
}
Here I used StatusCode >= 500 instead of @Level = 'Error', meaning the request logging events that count each failed request once. The threshold is 5; the traffic run had 8 responses with status 500. Within a minute, the alert's state (/api/alertstate) became:
"LastCheck": "2026-11-08T02:32:02.2262476Z",
"LastCheckTriggered": true,
"SuppressedUntil": "2026-11-08T02:52:02.2262476Z",
"RecentOccurrences": [
{
"DetectedAt": "2026-11-08T02:32:02.2262476Z",
"DetectedOverRange": {
"Start": "2026-11-08T02:26:32.2262476Z",
"End": "2026-11-08T02:31:32.2262476Z"
},
"NotificationLevel": "Error",
"Notifications": []
}
]
Notifications is empty because I didn't attach a notification channel to the alert. In a real setup you add an email, Slack or Teams app to Seq and route the alert to it. Don't skip SuppressionTime either: without it, every check sends a new notification for as long as the problem lasts, and the team mutes the channel.
Retention is the rule that keeps the disk from filling up. In Seq a retention policy can be tied to a signal: delete the summary lines of successful requests after 7 days, keep errors longer. I first created the "Request completion events" signal with the filter Has(StatusCode) and StatusCode < 400, then the policy:
{ "RetentionTime": "7.00:00:00", "RemovedSignalExpression": { "SignalId": "signal-27", "Kind": "Signal" } }
Since most of the volume is successful-request lines, this single rule saves most of the disk. How long logs that contain personal data should be kept is a legal decision, not a technical one; we'll talk about it in this series' sensitive data post.
In Production
Licensing. At the time of writing (November 2026), according to Datalust's pricing page, the Individual license is free: one user, 50 GB of storage. The page states plainly that an installation with the Individual license can't be accessed by more than one person. For teams, the Team license is 1,090 USD a year (up to 10 users, 100 GB), Standard is 2,990 USD and Datacenter is 10,990 USD. So "everyone logs in with the shared admin account" isn't a solution, it's a license violation. Prices can change; check the page before you buy.
When isn't Seq enough? Seq is a tool that works very well on a single server and takes minutes to set up. Once you have hundreds of GB of logs a day, a multi-region setup, or a need to keep logs next to metrics and traces for a long time on cheap storage, you need to look at horizontally scalable systems such as Elasticsearch or Grafana Loki. We'll build that infrastructure in the upcoming DevOps › Observability series. What you do right on the application side, meaning structured events, constant templates and OTLP, stays the same when the destination changes; only the exporter's address changes.
Network and security. Open the ingestion port (5341) only to the network where the applications live, and the UI port only to people. Create a separate API key per application: when one leaks, you revoke only that one, and the Application property tells you whose key it was.
Common Pitfalls
First, level names coming from OTLP. When I ran the app with OTLP instead of Serilog and tried the same queries:
select count(*) from stream where @Level = 'Error' -> 0
select count(*) from stream where @Level = 'Error' ci -> 2
select count(*) from stream group by @Level -> INFO 23, ERROR 2, OK 2
Serilog carries the level as Error, OTLP as ERROR, and in Seq = is case-sensitive. That's the answer to the third question. Use the ci (case-insensitive) modifier, or settle on a single sending path in a system. A span's @Level is OK or ERROR too; separate spans with Has(@SpanKind) in level-based counts.
Second, filters being read from a different place on the OTLP path. The Serilog:MinimumLevel:Override setting doesn't affect Microsoft.Extensions.Logging providers. In OTLP mode there was no Logging:LogLevel section, so every order request sent between 5 and 7 log lines to Seq ("Request starting", "Executing endpoint", "Setting HTTP status code 201." ...). Whichever path you choose, write the filters in that path's configuration.
Third, grouping by a high-cardinality property. group by on RequestPath, OrderId or a user's email produces one row per value. For grouping, use properties with a small number of values: route template, status code, event type.
Fourth, treating Seq as a log archive. Seq is aimed at fast search and incidents; for an audit record that must be kept for years, write those records to a separate, immutable store instead of making retention infinite.
A Small Experiment
★☆☆ Start Seq with the command in this post, connect the app from one of the earlier posts in this series to the Seq sink and run select count(*) from stream group by @Level. See which level makes up most of the volume.
★★☆ Write the query "the 5 slowest requests in the last 15 minutes" with the columns RoutePattern, Elapsed and @TraceId. Use one trace id from the result as a filter and fetch all events of that request.
★★★ Set up two alerts for payment failures: one counting events through @Level = 'Error', the other counting requests through StatusCode >= 500. Under the same traffic, compare with /api/alertstate which one fires when, and write down how you should tune the threshold.
In this post we saw that the CustomerId = 42 query came back empty, because the error logs didn't carry the request's business context. In the next post we close that gap: linking every log of a request with the same identity, from the API to an external service and to the worker behind a queue, with Activity, the W3C (World Wide Web Consortium) traceparent and a correlation id.