The Nuxt Observability Stack — Turning a Slow-Request Chart Into a Diagnosis
Hero image credit: Photo by mohamed Zekry on Pexels
Part 18 of the Nuxt and .NET series.
Part 17’s sizing experiment assumed you can see what the process is doing. This article is about acquiring that evidence, and about the discipline of not overstating what each piece of it proves.
The investigation below is a labelled fiction built from the document application. Diagrams are explanatory; nothing here is a timing export from a running system.
Table of Contents
- The Chart That Cannot Answer the Question
- Which Component Produces Which Signal
- Following One Request Through a Trace
- Correlation IDs Are Not Trace IDs
- Logs That Can Be Joined to a Trace
- Turning Up the Detail Temporarily
- Aggregates Without Unbounded Cardinality
- Memory and Garbage Collection Signals
- CPU Profiles and What They Can Attribute
- The Investigation Resolved
- Further Reading
- What’s Next
The Chart That Cannot Answer the Question
The alert says that tail latency on document pages has roughly doubled since this morning. The chart it came from shows one line going up.
That line is consistent with at least four different incidents: the .NET API got slower; the content service got slower; the shared cache is missing more often, so renders that used to be cheap now do the full cascade from Part 9; or the workers are CPU-saturated and requests are queuing for the event loop, which is Part 17’s failure mode. Each has a different fix, and three of the four fixes make one of the others worse.
No amount of averaging that line produces the answer, because the information was never in it. What is needed is a signal that decomposes one request into its parts, plus enough surrounding evidence to tell whether that request was representative.
Which Component Produces Which Signal
The most common source of confusion in this area is assuming that enabling a telemetry feature means data appears. Producing a signal and collecting it are separate steps with separate owners.
| Signal | Who produces it | How it gets collected |
|---|---|---|
| Incoming requests and outbound dependency calls | Application SDK or explicit instrumentation | Telemetry backend, directly or via a collector |
| Reverse-proxy spans | The proxy’s OpenTelemetry module | OTLP export to a configured collector |
| Structured application events | The application’s logger | Console plus selected telemetry sinks |
| Container CPU, memory, replica count, restarts | The platform | Platform metrics |
| Container stdout and stderr | The container processes | Configured platform log destination |
| Heap, GC and event-loop diagnostics | Node.js runtime instrumentation | Application metrics and protected diagnostic endpoints |
Three qualifications keep that diagram honest.
A managed agent routes telemetry that is sent to it. Turning it on does not instrument containers that produce nothing — a stock cache image does not acquire application tracing by sharing an environment with an instrumented Nuxt server. Instrumentation is the application’s job; the agent is transport.
Destinations differ by signal type. A managed agent may accept logs and traces for one backend while metrics require a separately chosen path. That asymmetry is worth writing into the infrastructure diagram and the troubleshooting runbook, because discovering it during an incident is expensive.
And the diagram shows available paths, not a recommendation to export everything twice. Duplicate instrumentation double-counts requests and doubles ingestion cost, which distorts exactly the numbers you will later reason from.
Following One Request Through a Trace
A trace decomposes the request. The proxy starts a span and propagates W3C Trace Context upstream; the application continues that context and adds child spans for the work it owns:
Two properties of this architecture affect what you will see. In-process gateway execution is not an HTTP hop, so automatic HTTP instrumentation records nothing for it — if you want to know how long composition took, or which subgraph a slow field came from, resolver-level spans have to be added deliberately. Conversely, where an HTTP client is already instrumented, adding manual spans around it produces duplicates that make a dependency look twice as busy as it is.
And trace coverage is a function of sampling. A backend cannot show you every request unless the collection and retention policy actually keeps them, which it usually does not at volume. That is fine for diagnosing a systematic regression and unhelpful for chasing one user’s complaint — worth knowing which kind of question you are asking before promising an answer.
Correlation IDs Are Not Trace IDs
Most applications also carry their own request identifier, for support tickets and log searches. It is genuinely useful and it is not the trace ID.
The distinction matters because of a specific bug: if a helper generates a fresh trace identifier instead of adopting the incoming context, the application’s spans form a separate trace from the proxy’s. Everything looks instrumented, and no trace contains the whole request. Take trace correlation from the active span context, and keep the application’s own identifier as a log field or span attribute.
Treat an externally supplied identifier as untrusted input, per Part 15: validate its format, bound its length, and never let a correlation header act as authentication.
Before anyone writes “end-to-end tracing” in a status document, verify it on a real request: the proxy span, the Nuxt request span, each dependency span and the API’s server span should be in one trace with the parent relationships you expect. That check takes five minutes and fails more often than people assume.
Logs That Can Be Joined to a Trace
A trace tells you where the time went. Logs tell you what the code decided. They are only usable together if they share a key, so log events should carry the current trace and span identifiers.
const log = useLogger('document-browser')
log.debug('Revisions loaded', { count: revisions.length })
log.info('Selection changed', { documentId })
log.warn('Cached result set was stale', { documentId })
log.error('Document load failed', { code: 'UPSTREAM_UNAVAILABLE' })
A module-scoped logger — using the module identity from Part 6 — gives every event an owner, which makes filtering meaningful. Beyond that, two rules keep logs affordable and safe. Use stable event names and a small documented set of fields, because searchability comes from consistency rather than from volume. And keep out request bodies, credentials, cookies, personal data and unfiltered error messages; prefer route templates like /documents/:id over full URLs, which is both safer and cheaper to aggregate.
The same logger can write to a readable console in development, to structured sinks in production, and to a development-only inspection panel. The panel is worth one caution: a browser-visible view must not carry server-only data. Server and client logging share an API, not a data set or a permission model.
Turning Up the Detail Temporarily
Running at debug level permanently is unaffordable and mostly noise, so the useful capability is raising the level for one module, briefly, in production.
The control endpoint is an operator capability, so it needs authentication, authorization, an audit trail, and an expiry — an override that never resets is a log bill and a data-protection question that nobody remembers creating.
One scoping detail that wastes a lot of incident time: with several workers per container and several replicas, an override applied through one HTTP request reaches one process. Unless it is distributed deliberately, “debug is on” means “debug is on in one worker, which may not be the one serving the request you care about.”
Aggregates Without Unbounded Cardinality
Traces are sampled and logs are per event, so steady-state questions — how many requests, how slow, what status — belong to aggregated metrics.
Counters and running aggregates keep storage independent of request count, and a histogram is what makes percentile estimates possible — an average and a maximum cannot produce a p95.
The claim to be careful with is “constant memory.” It is constant per bucket. Label with a full URL, a document id or a user id and the bucket count grows without limit, which is the classic cardinality incident: the metrics system, not the application, falls over. Bounded label sets are a design decision, not a detail. Instrumentation also costs CPU on every request, so constant storage is not constant overhead.
One architecture-specific note: with the in-process gateway, SSR does not issue an HTTP request to itself, so page views are not double-counted. If a deployment ever uses a loopback path instead, label internal calls separately or the same page render appears twice in traffic figures.
Memory and Garbage Collection Signals
Node.js exposes GC activity and heap-space statistics, which is what Part 17’s experiment consumes.
Interpretation requires comparable windows: a rising slope during cache warm-up is normal, and the same slope sustained across two similar traffic periods is not.
Heap snapshots need handling rules, because they are the most invasive tool here. Taking one can pause a worker and temporarily increase its memory substantially — so triggering one automatically when memory is already near the limit can convert a warning into the outage it was warning about. A snapshot also contains whatever was in memory, including credentials and request bodies, regardless of how carefully the logger redacts; treat captures as sensitive artefacts with restricted access, bounded retention and no casual sharing. And rate-limit any automatic capture, or one threshold flap produces a pile of multi-gigabyte files.
CPU Profiles and What They Can Attribute
A CPU profile distinguishes rendering work from response parsing from serialization, which is exactly what you need when the trace shows time spent inside the Nuxt span rather than in a dependency. It says nothing about time spent waiting, because waiting is not CPU.
Two attribution limits decide how strongly you can phrase a conclusion. A profile started after a request became slow cannot contain the work that happened before the trigger fired. And a Node.js worker handles concurrent requests, so the captured profile includes all of them — a profile is process-level evidence for a time window, and attributing it to one request requires correlating with traces and load rather than asserting it.
The same caution applies to capacity arithmetic. Arrival rate times average duration approximates concurrent work, and per-request memory is not the observed heap delta, because caches, GC timing and overlapping lifetimes are all in that number. Which is why Part 19’s load test exists rather than a spreadsheet.
The Investigation Resolved
Back to the doubled tail latency. The path through the signals above is:
- Which route class and which release? Metrics with bounded route labels, split by release, which either implicates this morning’s deployment or exonerates it.
- Waiting or executing? One trace of a slow request. If the time is in a dependency span, the application is waiting; if it is inside the Nuxt span with no child covering it, the application is working.
- What changed in the process? Platform metrics and Part 17’s signals: replica count, restarts, memory, event-loop delay. A capacity problem looks different from a dependency problem.
- What did the code decide? Redacted logs joined by trace id, showing retries, timeouts and cache outcomes — which is where “the cache is missing more often” becomes visible as a cause rather than a guess.
- Is a profile or snapshot justified? Only if the previous four left the question open, and only with the handling rules above.
In the fictional case, the trace puts the added time in content-service dependency spans, the logs show cache misses where there used to be hits, and the platform metrics are unremarkable — which points at an invalidation that cleared more than intended, not at a slow dependency and not at capacity. The fix is in the cache identity, and it would not have been found by scaling anything.
One evidence-handling habit is worth keeping from this: do not discard client-aborted requests wholesale. They are sometimes ordinary navigation and sometimes the signature of responses so slow that users leave, and those two look identical unless you kept enough to tell them apart.
The rule: every signal answers one question. Traces say where, logs say what was decided, metrics say how often and how bad, profiles say what the CPU did, and platform metrics say what the process was given. An observability stack is finished when each question has an owner — not when the ingestion volume is impressive.
Further Reading
What’s Next
- Part 19: Load Testing an SSR Migration — Producing comparable numbers, and reporting only what they support.
- Part 20: The Full Picture — Whether the whole arrangement was worth owning.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.
Category: Advanced Web App With Nuxt And Net