OpenTelemetry Is Not the Three Pillars
29 Aug 2026Checkout fails for one cohort. Metrics show p99 inside budget. Logs mention the user as userId, user_id, and customer. The trace names a slow span and carries none of the product fields you need. You spend forty minutes joining three tools by hand.
Plenty of teams call that stack "OpenTelemetry" and stop there. The second edition of Observability Engineering has a chapter title for it: "The Dominant Model of 'Observability' Is Just Monitoring, Rebranded". The outdated part is not the signals. It is shipping each one into its own silo and calling the result observability.
The incident you already know
You open the metrics tab first because it is cheap and always on. Latency looks fine. Error rate ticks up a little. Nothing tells you which users or which payment path.
You switch to logs. Grep finds fragments. The user id appears in three shapes across services that never agreed on a field name. You stitch timestamps until a story almost appears.
You open the trace. The call graph is clear. The span that hurt is obvious. The fields that would finish the investigation (payment.provider, feature.flag, region, plan) never left the process, so they are not on the span.
You did the work a senior on-call does every week: jump, reconcile identifiers, rebuild context that the systems threw away.
What "three pillars" actually names
Metrics, logs, and traces are useful signal types. The three-pillars model is something else: each type lands in its own store (time-series database, log aggregator, tracing backend), and investigation means correlating across those stores after the fact.
Charity Majors, Liz Fong-Jones and George Miranda, with Austin Parker on the second edition, call that siloed practice the "legacy three pillars observability model". Their name for the alternative is the unified model: wide, high-cardinality structured events you can slice without rebuilding context by hand. Both models, they say, support operational loops. The unified one "shines at outlier detection and exploratory investigations" by tenant, region, endpoint or feature state, and "eliminates the need for tool hopping or cross-correlation".
They keep the pillars language for infrastructure you did not write and cannot change. The three-pillars model, in their words, "evolved out of the monitoring and logging space" and is "designed for infrastructure, where engineers must operate hundreds if not thousands of third-party components that they did not write and cannot change". Third-party gear emits what it emits; cheap siloed storage fits that job. The code that defines your product is a different job. You control what you emit. Treating that code like a black box router wastes the only leverage you have.
OpenTelemetry sits on the other side of that distinction. It is the wire and the API: one instrumentation surface, OTLP export, backends you can swap. Exporting metrics, logs, and traces through OTel does not force you into three investigation products. Teams still do that because vendors sold pillars, ops owned the tools, and muscle memory followed.
---
alt: "App telemetry splitting into three stores versus one wide event exported over OTLP."
---
flowchart LR
subgraph pillars ["Three pillars model"]
Code1["App"] --> M["Metrics store"]
Code1 --> L["Log store"]
Code1 --> T["Trace store"]
end
subgraph unified ["Unified model"]
Code2["App"] --> Wide["Wide structured event"]
Wide --> OTLP["OTLP, any backend"]
end
Why the habit fails for application code
Aggregated metrics answer "is something wrong?" They lose the rows that would answer "for whom?" and "under which flag?"
Scattered logs answer "what printed?" They force you to reconstruct the request from timestamps and inconsistent keys.
A trace without product context answers "where was time spent?" It will not answer "which provider declined for this plan in this region?" unless you put those fields on the event while the request ran.
The book calls it the arbitrary question test: can more engineers answer novel questions without escalating? That is the one that matters in production. Can you ask, right now, for mobile users in the EU with feature flag X who errored in checkout? Siloed, pre-aggregated pillars make that question expensive or impossible. A wide structured event with those fields already attached makes it a filter.
I wrote about wide events as the default and about why the check has to ship with the change. This post is the naming fight underneath both: stop equating "we adopted OTel" with "we run the three pillars."
Agents make the gap louder
Humans paper over missing context with intuition and heroics. Agents cannot.
The book puts the cost on both: the legacy model "creates cognitive overhead by forcing engineers (or AI in some cases) to decide where to start, jump between systems, reconcile identifiers and timestamps, and make error-prone correlations". A person does that badly on a bad night. An agent does it badly every time, because the reconciliation it needs is judgement about which userId field meant what, and that judgement was never written down.
Faster deploys raise the rate of novel failure modes. Pre-built dashboards cover last quarter's questions. You need fields you kept on the request, not slides you drew for the last outage. I wrote about what that means for agent systems specifically in You Can't Ship Agents Like Software.
Instrument for questions you have not asked yet
Keep the call graph. Put high-cardinality context on the same event: user, plan, cart, payment provider, flag, error code. Export over OTLP so you own the data path.
With Autotel that shape looks like this:
import { init, withTracing, setUser, httpServer } from 'autotel';
import pino from 'pino';
const logger = pino({ name: 'checkout-api' });
init({
service: 'checkout-api',
logger,
canonicalLogLines: { enabled: true, rootSpansOnly: true, logger },
});
export const processCheckout = withTracing({})(
(ctx) => async (req: CheckoutRequest) => {
setUser(ctx, { id: req.userId });
httpServer(ctx, { method: 'POST', route: '/api/checkout' });
ctx.setAttributes({
'cart.id': req.cartId,
'cart.item_count': req.items.length,
'payment.method': req.paymentMethod,
'payment.provider': 'stripe',
});
// business work; failures attach error.* on the same span
return charge(req);
},
);
One instrumentation path. A canonical wide event per request. Real spans and metrics on the same OTLP pipe. Backend choice stays yours.
"That is expensive"
It is the first objection, and it is fair. A wide event per request with fifty fields costs more to ingest than a counter.
The book's answer is a whole chapter, "Cheap and Accurate Enough Sampling", and its reasoning is worth repeating: "many of their emitted events are virtually identical and successful", so paying to store all of them buys you very little. Keep every failure and a representative sample of the successes, and you keep the ability to ask arbitrary questions at a fraction of the cost.
That is a sampling decision, not an instrumentation decision, which is the point. You attach the fields once, while the request is running, and decide later what to keep:
init({ service: 'checkout-api', sampling: 'production' }); // 10% baseline, all errors
if (payment.status === 'declined') forceKeep(); // this one, whatever the sampler concluded
Aggregate first and the fields are gone forever. Sample afterwards and they are only gone from the runs you chose not to keep.
Run the A/B yourself
Clone the Autotel repo and run the teaching demo:
git clone https://github.com/jagreehal/autotel.git
cd autotel
pnpm install && pnpm --filter autotel build
cd apps/example-pillars-vs-unified
pnpm start:pillars # siloed metric + logs + bare span
pnpm start:unified # one wide event with the fields you need
Both modes fail the same checkout for user_456, then try to answer one question from what they emitted:
# pillars
Question: Which payment.provider failed for user_456?
Answer: unknown from this output alone.
7 records: 2 name the user (log store, log store), 0 carry payment.provider. No record has both.
# unified
Question: Which payment.provider failed for user_456?
Answer: payment.provider = "paypal"
Read off one record in the one wide event. No tab hopping.
The answer is not narration. Each mode collects what it emitted as records and runs the same search over them: find one record that names the user and the provider together. Both modes assert their result, so the demo exits non-zero if siloed telemetry ever answers the question, or if the wide event stops answering it.
App: apps/example-pillars-vs-unified.
Keep the wire. Retire the ritual
Adopt OpenTelemetry for portability. Emit wide, structured context for the services you write. Use siloed metrics and logs where you operate black-box infrastructure. Stop treating three browser tabs as the definition of done for one failed request.