Once a routing shim is in front of the legacy app and one slice is answering real URLs, a question arrives within about a week: a user says "the order page was slow this morning," and nobody can say whether the time was spent in the shim, in the new .NET 8 slice, in the old WebForms page, or in SQL Server. Each of those has its own log, in its own format, with its own clock.
This tutorial wires a single trace identifier through all of them. By the end you will be able to take one request ID from a user's screenshot and pull the shim span, the .NET 8 spans, the legacy page's own log line and the SQL statement it ran, in order, on one timeline.
You need a .NET 8 reverse proxy in front of the legacy app (the routing shim pattern), and the ability to add one include file or one HTTP module to the legacy application. Nothing else in the legacy code changes.
Mint the identifier once, at the front door
The shim is the only component every request passes through, so it owns the identifier. .NET already generates one: the W3C traceparent. Turn it on and export it.
dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService("shim"))
.WithTracing(t => t
.AddAspNetCoreInstrumentation(o =>
{
o.RecordException = true;
o.EnrichWithHttpRequest = (activity, req) =>
activity.SetTag("shim.route", req.Path.Value);
})
.AddHttpClientInstrumentation()
.AddOtlpExporter());
AddHttpClientInstrumentation matters more than it looks. YARP forwards through HttpClient, so this is what produces a span covering the proxied call to the legacy app, which is the number you actually want: time spent downstream, measured from the front door.
If you have no collector yet, run one locally and look at traces in a browser before you plumb anything else:
docker run --rm -p 18888:18888 -p 4317:18889 \
mcr.microsoft.com/dotnet/aspire-dashboard:9.0
Point OTEL_EXPORTER_OTLP_ENDPOINT at http://localhost:4317. The Aspire dashboard is a fine permanent answer for a single-app shop; a hosted OTLP backend is a fine answer if you already have one. The OpenTelemetry .NET docs cover the exporter options.
Hand the identifier to the legacy app
The legacy app cannot read an Activity. It can read a header. Add a small transform in the shim so every proxied request carries the current trace in a form a twenty-year-old page can parse.
app.MapReverseProxy(pipeline =>
{
pipeline.Use(async (context, next) =>
{
var activity = Activity.Current;
if (activity is not null)
{
context.Request.Headers["X-Correlation-Id"] = activity.TraceId.ToString();
context.Request.Headers["X-Parent-Span-Id"] = activity.SpanId.ToString();
}
await next();
});
});
Two headers, both plain hex strings. X-Correlation-Id is the join key. X-Parent-Span-Id is what lets you nest the legacy work under the shim span later, if you decide to.
Also echo the correlation ID back to the browser, so a screenshot is enough to find the trace:
context.Response.OnStarting(() =>
{
context.Response.Headers["X-Correlation-Id"] = Activity.Current?.TraceId.ToString() ?? "";
return Task.CompletedTask;
});
For a WebForms app, a small master-page label rendering Request.Headers["X-Correlation-Id"] in the footer saves more support time than it costs. Nine characters of it is enough to search on.
WebForms: one HTTP module, no page changes
public class CorrelationModule : IHttpModule
{
public void Init(HttpApplication app)
{
app.BeginRequest += (s, e) =>
{
var ctx = HttpContext.Current;
var id = ctx.Request.Headers["X-Correlation-Id"];
if (string.IsNullOrEmpty(id)) id = Guid.NewGuid().ToString("N");
ctx.Items["CorrelationId"] = id;
log4net.LogicalThreadContext.Properties["cid"] = id;
};
app.EndRequest += (s, e) =>
{
var ctx = HttpContext.Current;
LegacyLog.Write(
cid: (string)ctx.Items["CorrelationId"],
path: ctx.Request.Path,
status: ctx.Response.StatusCode,
ms: (int)(DateTime.UtcNow - ctx.Timestamp.ToUniversalTime()).TotalMilliseconds);
};
}
public void Dispose() { }
}
Register it in web.config under system.webServer/modules. No .aspx file is touched, which means no recompilation argument with whoever owns the legacy build.
If the app uses log4net or a home-grown logger, the useful move is the cid property above: existing log lines start carrying the correlation ID without any of them being rewritten.
Classic ASP: one include, applied at the top
Classic ASP has no module pipeline, so the include goes at the top of the common header file that every page already includes. If there is no such file, that is worth knowing on its own.
<%
Dim gCid
gCid = Request.ServerVariables("HTTP_X_CORRELATION_ID")
If gCid = "" Then gCid = Replace(CreateObject("Scriptlet.TypeLib").Guid, "{", "")
Application("StartTick_" & gCid) = Timer()
%>
And in the common footer include:
<%
Call LogRequest(gCid, Request.ServerVariables("SCRIPT_NAME"), _
Int((Timer() - Application("StartTick_" & gCid)) * 1000))
Application.Contents.Remove "StartTick_" & gCid
%>
Keep LogRequest dumb: one INSERT into a table, errors swallowed. Telemetry that can take the application down is worse than no telemetry.
Make SQL Server show you the same ID
The slowest part of a legacy request is usually a query, and query plans are anonymous by default. Tag them. In the legacy data access layer, or in the new slice's connection setup, stamp the correlation ID on the session:
using var cmd = connection.CreateCommand();
cmd.CommandText = "SET CONTEXT_INFO @cid";
cmd.Parameters.AddWithValue("@cid", Encoding.ASCII.GetBytes(correlationId.PadRight(32)));
cmd.ExecuteNonQuery();
Now an Extended Events session capturing sql_batch_completed or rpc_completed can carry CONTEXT_INFO, and the statements a given request ran are recoverable after the fact rather than during a reproduction attempt. If touching the legacy data layer is not on the table, an application-name suffix in the connection string (Application Name=legacy-orders) is a coarser but zero-risk fallback: it at least separates old-path from new-path load in sys.dm_exec_sessions.
Join the three logs
You now have shim spans in an OTLP backend, legacy request rows in a table, and SQL events. A single query answers the support ticket:
SELECT cid, path, status, ms
FROM legacy_request_log
WHERE cid = 'e9b1c0a4f2d34f7e8a0b5c6d7e8f9012'
ORDER BY logged_at;
Paste the same ID into the trace backend and you get the shim span and the .NET 8 slice spans around it. The comparison people care about shows up immediately: the same logical operation, old path and new path, side by side with real durations. That is also the evidence a cutover conversation needs, and it pairs directly with shadow traffic parity numbers.
What to watch for
- Clock skew. If the legacy IIS box and the shim host are minutes apart, the joined timeline is fiction. Confirm both are syncing to the same time source before you trust any ordering, and log UTC everywhere.
- Sampling. Head-based sampling at 10% will discard the one trace the user is asking about. During a migration, sample proxied requests at 100% and control cost by retention instead. Volume on a mid-market line-of-business app is small enough that this is affordable.
- PII in spans.
EnrichWithHttpRequestmakes it easy to attach query strings, and legacy apps put customer numbers, and occasionally national IDs, in query strings. Allow-list the tags you record. Do not record raw URLs from a system you have not inventoried. - Legacy logging cost. One insert per request is fine. One insert per database call from a Classic ASP page that runs forty queries is not; you will measure the telemetry. Start at request granularity, and add depth only where a trace has already pointed you.
- The identifier is not a fix. Correlation tells you where the time goes. It does not tell you what to do about it, and on a WebForms page the answer is often that the page does eleven queries because it was built that way. Record that as a slice candidate rather than a tuning task.
The whole setup is about a day of work, most of it in the legacy footer include, and it changes what the migration conversation sounds like. "The old page takes 2.4 seconds and the new one takes 240 milliseconds, here is the trace" ends a debate that opinions cannot.