A trace would go dark the instant a request crossed into our Classic ASP layer, then pick back up downstream with nothing tying the two halves to the same customer click. Every OpenTelemetry tutorial assumes a stack born after 2015: a runtime with an auto-instrumentation agent, a DI container to wire a tracer into, a codebase you can decorate with spans. My production app is Classic ASP on IIS, talking to MSSQL, and large chunks of it are twenty years old. There is no OTel SDK for VBScript and there never will be. I still wanted distributed traces across that gap, and I got them without touching the core ASP logic. The trick is a Node sidecar and a forwarding contract built out of a single HTTP header.
This is the pattern the tutorials skip: you do not instrument the legacy layer. You make it forward enough context that a modern process can emit spans on its behalf.
The problem with instrumenting the thing you can’t rewrite
The honest constraint up front: I am not going to add a span library to a Classic ASP file. There is no good one, the language has no proper exception model to hang a span lifecycle on, and every line I touch in that codebase is a line that can break a paying customer’s tournament page at 11pm. The whole point of the exercise is observability into the legacy stack, not a rewrite of it.
So the ASP layer’s job in this design is deliberately tiny. It does exactly one thing: it reads an inbound trace context header, and it passes that same context downstream to the Node service it calls. That is the entire footprint inside ASP. Everything else, the span creation, the timing, the export to a collector, lives in the sidecar where I actually have modern tooling.
The W3C trace context spec gives you exactly the seam you need. A traceparent header carries the trace ID, the parent span ID, and the sampling flags in one compact string. If ASP can read that header on the way in and hand it back out on the way to the next hop, the trace stays continuous across a process boundary that OTel was never designed to cross.
The IIS forwarding contract: how a header becomes a ServerVariable
My first attempt read Request.ServerVariables("HTTP_TRACEPARENT") straight off the incoming request, because that is the header name the spec defines and I assumed IIS would just hand it back under the same name. It came back empty every time. Nothing threw. ASP does not throw on a missing ServerVariable, it hands you an empty string and lets you find out the hard way, so I spent a debugging pass staring at a sidecar that kept starting fresh root traces before I went looking at how IIS actually builds that collection. When a traceparent header arrives at IIS, you cannot read it from ASP under that name. IIS folds inbound HTTP headers into the ServerVariables collection with an HTTP_ prefix, dashes replaced with _ characters, and the name uppercased. So you do not read traceparent. You read the variable IIS built from it.
The cleanest version of this is to have your edge (a reverse proxy, or the Node service that fronts the request) set an explicit custom header rather than relying on the raw traceparent name surviving the hop. I forward it as X-Traceparent. IIS then exposes it as HTTP_X_TRACEPARENT, and the ASP read is one line:
Dim traceparenttraceparent = Request.ServerVariables("HTTP_X_TRACEPARENT")That string is the whole seam. HTTP_X_TRACEPARENT is where a legacy request handler reaches out and grabs a modern distributed trace context, with no library, no SDK, no compiled component. If the header is absent (a request that did not originate through the instrumented path), traceparent comes back as an empty string and you start a fresh root trace at the sidecar instead. That fallback matters, because not every request to this app enters through the new front door.
The contract has two halves and both have to be stated explicitly or it silently breaks:
- Whoever calls into ASP must send the context as
X-Traceparent(or whatever custom name you standardize on). Pick one name and write it down. The forwarding is a contract, not a convention you can leave implicit. - ASP reads it as
HTTP_X_TRACEPARENTand includes it on the outbound call to the next service.
The reason to use a deliberate X- custom header instead of leaning on traceparent directly is that you control it end to end. You are not depending on proxy software preserving a header name you did not choose, and the HTTP_X_ mangling is predictable. When something downstream is missing context, you have exactly one variable name to grep for on both sides of the boundary.
The sidecar emits the spans
The Node sidecar is where the actual OpenTelemetry lives. It runs as a separate process next to the ASP app and it is the only thing in this design that knows what a span is.
When the ASP layer calls into the sidecar, it passes HTTP_X_TRACEPARENT along. The sidecar parses that traceparent string back into a real OTel span context and starts a new span as a child of it. From the trace’s point of view, the ASP request is now a parent span and the work the sidecar does is its child, even though the ASP side never created a span object in its life. The sidecar does the timing, sets the attributes, handles the sampling decision encoded in the trace flags, and exports to the collector.
This is the inversion that makes the whole thing work. You are not asking the legacy layer to participate in OTel. You are asking it to carry one string faithfully across a boundary, and you let a process that speaks fluent OTel emit spans on its behalf. The ASP app gets represented in your distributed traces without ever importing a tracer.
Get the header seam right first, prove context survives the hop, then layer on what the sidecar actually records. If you try to do all of it at once you cannot tell whether a gap in your traces is a broken span or a dropped header.
Why this is the right shape for legacy Windows stacks
The instinct when you have a creaky old app is to assume modern observability is off the table until you rewrite. That instinct is wrong, and it is expensive. The realistic move is to find the one seam where the legacy layer touches a modern process, and make that seam carry trace context.
traceparent is the most useful thing to forward, but the mechanism is general. This same HTTP_X_ forwarding contract is how you’d thread a request ID, a tenant ID, or any cross-cutting context into an app that predates the concept.
A few hard-won notes if you build this:
- Be exact about the header name on both sides. The
X-TraceparenttoHTTP_X_TRACEPARENTtranslation is mechanical, but a typo or a proxy that stripsX-headers will give you empty strings and silent gaps, not errors. Classic ASP loves to fail by handing you an empty value instead of throwing. (If you have ever fought an emptyRequest.Formcollection because a modern frontend sentFormDatainstead ofapplication/x-www-form-urlencoded, you already know how ASP punishes you with silence rather than a stack trace. Same failure personality here.) - Always handle the empty-traceparent case as “start a fresh root span,” not as an error. Requests that bypass the instrumented entry point are normal, and you want a trace for those too.
- Keep the ASP footprint to the two operations only: read the inbound context, attach it to the outbound call. The moment you start doing more inside ASP, you are back to instrumenting the thing you said you would not instrument.
The takeaway is bigger than one app. A legacy Windows stack does not have to be a black hole in your observability pipeline, and you do not have to rewrite it to light it up. You need a forwarding contract and a sidecar. The forwarding contract is one custom header and the HTTP_ prefix rule that IIS has had since the Clinton administration. The sidecar is a small modern process that speaks OTel and is willing to emit spans for a layer that can’t speak for itself. Find the seam, carry the context across it, and a legacy codebase joins your distributed traces without a single line of its core logic changing.
Related
- UTC logs, a local clock, and the canary request: timezone discipline in an incident: log-correctness discipline that matters once you have distributed traces to correlate
- A fluent DOM builder for legacy ASP: modernize without a rewrite: another incremental modernization pattern for classic ASP without touching core logic
- Server-Side JavaScript on Classic ASP in 2026: Prototype Pages and the DBobj Pattern: a complementary pattern for adding modern logic to the same legacy ASP layer
- Your IIS Logs Start Lying the Moment Cloudflare Goes Live: the CF-Connecting-IP header shift that breaks log attribution at the same CDN seam
- Don’t invent a new style, expose the existing one: a product-taste lesson in feature design: the discipline of working with the existing conventions rather than building parallel ones