Three of eighteen features could prove they were used, and none of them sold anything


A shopkeeper holding a hand tally counter turns to the bell over the door as a customer leaves, beside an open ledger headed tracking plan and an old cash register, under a blackboard that asks which event proves it is used and answers 3 of 18.
The shopkeeper asks what proves each part of the shop is used. Only three of eighteen can answer.

TL;DR

Continuing our series on Tendero, the AI-native ecommerce platform, in this article I make the shop observable, from the .NET services to the browser, and name the event that proves each feature is used.

  • I asked every feature one question: which signal proves it is used, and worth it? Three of eighteen could answer, and none of them sold anything.
  • On a laptop, the Aspire dashboard stays, and one switch adds Grafana, which keeps history. Outside the laptop, Grafana is the one that matters. The browser sends telemetry too, so one click is one trace from the page to the search engine.
  • The first dashboard lied twice: once about conversion, once about what it sent. Both are fixed, and the second one mattered more.

Tendero had traces in nearly every .NET slice, custom metrics in two files, and nothing at all from the browser. Everything went to the Aspire dashboard, which keeps its data in memory and forgets it when you stop the app.

So before building more features, I asked each existing one a single question:

Which event proves it is used, and that it is worth it?

Eighteen features. Three could answer with a signal the code already sent: the AI search layer, the outbox, and the WebMCP tools. All three are plumbing. Search, the cart, checkout, returns, publishing a product — none of them could say, in numbers, that anybody used them.

This article closes that gap: where the telemetry goes, what each tool adds, how the browser joins in, and the rule that now stops a feature from shipping silent.

A few words first, because the rest of the article uses them:

  • Telemetry is what a running program reports about itself. It comes in three kinds: traces, metrics and logs.
  • A trace is the story of one request. It is made of spans: one span for each step, such as «the API answered» or «Elasticsearch searched», each with its start and its duration.
  • A metric is a number over time: orders per minute, searches with no result.
  • A log is a line of text with a time and some fields.
  • OpenTelemetry (OTel) is the open standard for all three. OTLP is its protocol: the format and the way telemetry travels from a program to whoever stores it.

Two ways to watch the shop

Tendero runs locally with .NET Aspire. Aspire starts every part of the shop with one command, and it comes with a dashboard that shows the telemetry of all of them. That dashboard is very good at one thing: showing a developer what just happened. It has no history, no dashboards of your own, and no way to ask «how did this look an hour ago?».

I did not replace it. I added a second mode behind a switch:

Tendero__Observability=true dotnet run --project src/AppHost

→ Reference: src/AppHost/AppHost.cs, lines 307–308, where the switch is read

How Tendero's telemetry travels in its two modes. By default, the .NET services and the browser, through its own server, send OTLP straight to the Aspire dashboard, which keeps it in memory. With the switch on, everything goes to an OpenTelemetry Collector inside one container, grafana/otel-lgtm, which stores traces in Tempo, metrics in Prometheus and logs in Loki for Grafana to draw, and also sends a copy to the Aspire dashboard.

With the switch on, Aspire also starts one container, grafana/otel-lgtm. It is a single Docker image that Grafana publishes for development and demos, and inside it there are five programs, each one in its own process. LGTM stands for four of them: Loki, Grafana, Tempo and Mimir. This image uses Prometheus for metrics instead of Mimir, but the name stayed.

ProgramWhat it doesThink of it as
OpenTelemetry CollectorReceives everything over OTLP and sends each kind where it belongsA router for telemetry
TempoStores tracesA store of spans
PrometheusStores metricsA database of numbers over time
LokiStores logsA store of log lines
GrafanaDraws and queries the three storesThe screen

This is the path:

services ──OTLP──► Collector ──► Tempo (traces)
├──► Prometheus (metrics)
├──► Loki (logs)
└──► Aspire (the copy we added)
Grafana ──queries──► Tempo, Prometheus, Loki

The image comes with the Collector, the three stores and Grafana already connected to each other. We added three things: the copy the Collector sends on to the Aspire dashboard, the receiver for Faro (more on it below), and the Shop pulse dashboard, loaded when the container starts. Every service sends its telemetry to the Collector instead of to the dashboard, so you gain a view with history, and you lose nothing: the dashboard you had open still works.

It did not work the first time. The Aspire dashboard started refusing hundreds of requests a minute with invalid wire type, and none of them came from Tendero. Aspire’s WithOtlpExporter() on the container gave the standard OTEL_ variables to every program inside it — Grafana, Tempo, Loki, Prometheus — and each one started sending its own telemetry straight at the dashboard. I found it by stopping the Collector and watching the error count freeze. The fix copies the dashboard’s address under two names that only the Collector reads, and removes every OTEL_ variable from the container.

→ Reference: src/AppHost/AppHost.cs, lines 832–850

What each piece gives us, and why it is here

Observability brings many names at once. The container’s five programs are above; these are the rest — what sends the telemetry and the dashboard that shows it by default — with what each one adds and what we would miss without it.

PieceWhat it isWhat it gives usWithout it
OpenTelemetry .NET SDKThe libraries every .NET service loads, through Aspire’s service defaultsA span for every request, every HTTP call and every slice that does I/O; runtime metrics; the shop’s own countersThe dashboard would show console output and little else
Aspire dashboardComes with Aspire; receives OTLPTraces, metrics and logs of every service, live, with no setupEach service would be a separate console window
OpenTelemetry web SDKThe same standard, in the browserSpans for the page’s requests, its errors and its events, joined to the API’s spansEvery trace would start at the API, and the browser would be invisible
web-vitalsGoogle’s small library for Core Web VitalsHow fast the page appears and answers, measured on real visitsWe would guess how fast the shop feels
Grafana FaroGrafana’s own SDK for the browserA second browser adapter, used to compare against OpenTelemetryNothing is lost: OpenTelemetry is the default

The versions at this tag: OpenTelemetry .NET 1.18.0, the OpenTelemetry web SDK 2.11.0, web-vitals 6.2.2, Faro 2.12.1, and grafana/otel-lgtm 0.33.1. The libraries are Apache-2.0, so they can ship inside Tendero. Inside the container, Prometheus and the Collector are Apache-2.0 too, and Grafana, Tempo and Loki are AGPLv3. That is fine for a container we run beside the app and never change; it would not be fine for a library we linked.

→ Reference: src/ServiceDefaults/HostingExtensions.cs, lines 51–69, where every .NET service turns OpenTelemetry on and sends OTLP to whatever address Aspire gives it

That last point is the whole trick of the switch. A service does not know where its telemetry goes. Aspire tells it, in one variable, OTEL_EXPORTER_OTLP_ENDPOINT, when it starts the service: the dashboard by default, the Collector with the switch on. No service changes.

On your machine, and everywhere else

Everything above runs on one machine. That is where Aspire is at home: the AppHost starts the whole shop with one command, and its dashboard shows one developer what just happened. Locally, that is almost always enough, and Grafana is optional.

Outside the laptop — a test server, a staging environment, production — the questions change. Many people look at the same shop. They ask about yesterday and last week, not the last minute. Somebody wants an alert when checkouts fail, not a page they must remember to open. The Aspire dashboard can run on its own, but it still keeps everything in memory, for whoever has it open. This is where Grafana takes the weight:

On your machineAnywhere else
Who looksYou, while you codeThe team, every day
What you ask«What did that click just do?»«How did checkout do this week?»
Where telemetry goesThe Aspire dashboard; Grafana if you turn it onA Collector, then stores that keep it, then Grafana
HistoryGone when you stop the appKept for as long as you decide
AlertsNoneGrafana’s, over the same series

The code does not change between the two. A service only reads OTEL_EXPORTER_OTLP_ENDPOINT, so in another environment it is pointed at that environment’s Collector. The Shop pulse dashboard is a file in the repository, so it travels too. What changes is what runs beside the shop: grafana/otel-lgtm puts everything in one container, which is right for a laptop and a demo, and wrong for production: it cannot grow, and if the container stops, all five programs stop with it. There, the Collector, Tempo, Prometheus (or a store built for more data), Loki and Grafana run as separate services, or a hosted Grafana takes their place.

Tendero is not deployed anywhere yet; it is a laboratory. So the right column is the direction, not something I have measured. But the left column was built so that the right one needs no new code.

The browser joins the trace

In .NET, OpenTelemetry is already the abstraction, so no port is needed. In the browser it is different. OpenTelemetry’s web SDK is still experimental, and Grafana Faro, the main alternative, has its own API. So the frontend gets a port, libs/shared/telemetry, with only what our code sends — events, errors, spans, the current route — and each SDK becomes an adapter behind it.

Three rules shape it:

  • The page never waits for telemetry. The adapter is loaded after the first render, as a separate file. The OpenTelemetry one is 26 KB gzip, and the first download of the page grew by only 0.8 KB.
  • Telemetry never breaks the shop. If an adapter fails to load, fails to start or fails later, a do-nothing adapter takes its place, and a test proves the page carries on.
  • The browser sends to its own server. The page posts to /otlp on the server that serves the page, and that server forwards it. (The Faro adapter posts to /faro the same way.) Aspire gives the page’s server the same address and key it gives every service. So there is no CORS problem, nothing an ad blocker can match, and no dashboard key inside the page.

→ Reference: frontend/libs/shared/telemetry/src/lib/provide-telemetry.ts, lines 35–38, where each adapter is loaded only when needed, and frontend/apps/backoffice/proxy.conf.mjs, lines 28–55, where the page’s server forwards /otlp

With that in place, one click is one trace. The browser’s request carries a traceparent header, a standard header with the trace’s id, so the API’s spans join the browser’s:

One trace in the Aspire dashboard: the storefront's HTTP GET takes 47 ms in the browser; inside it, the API's GET /api/search/cards takes 8.86 ms, its search.cards and search.profiles spans follow, and the last span is the HTTP POST to Elasticsearch, 6.79 ms.

Look at the two numbers at the top. The API worked for 9 ms. The shopper waited 47 ms. The other 38 ms are the network and the browser itself. Without telemetry from the browser, we would only see the 9 ms, and believe the shop is faster than it feels.

The question, as a rule

The question at the top of this article is now part of Tendero’s definition of done. A feature names the signal that proves it is used, and the decision its value would change. A signal with no decision does not enter the plan.

The plan is one JSON file, docs/telemetry/tracking-plan.json: the list of everything the shop measures, and why. It has three lists:

  • features: for each feature, the question we want the data to answer, the decision the answer would change, and the signals that give the answer.
  • metrics: every number the .NET services send, with its type, its unit and the attributes it may carry.
  • events: the same for what the browser sends, plus the consent each event needs.

So a signal named in a feature is also defined in the same file, and nothing is measured «just in case». This is one entry of features, the one for returns:

{
"name": "Returns",
"area": "storefront",
"question": "Why do things come back?",
"decision": "Wrong size and not-as-described are the product reasoning layer's case (phase 10); damaged and wrong item are the warehouse's.",
"signals": [
"tendero.returns.requested"
]
}

→ Reference: docs/telemetry/tracking-plan.json, lines 68–77

Read it like this: the question is why things come back, and the counter tendero.returns.requested answers it, because it counts every line of a return with its reason, one of five: wrong size, not as described, damaged, wrong item, or changed mind. The decision says what we do with the answer. If things come back because the size was wrong or the description was not true, the fix is better product information. If they come back damaged, or the wrong item was sent, the fix is in the warehouse.

The JSON is the only place where the plan is written. A small Node script, tools/tracking-plan.mjs, reads it and writes two other files from it:

  • A page for people, docs/telemetry/tracking-plan.md. It is the same plan as a normal document: each feature with its question, its decision and its signals. Nobody has to read JSON to know what the shop measures.
  • A TypeScript type for the frontend, tracked-events.ts. It lists every event the browser may send, with its attributes and the values each one allows. This is one entry:
readonly webmcp_tool_invoked: {
readonly tool: string;
readonly outcome: 'answered' | 'refused' | 'failed';
};

→ Reference: frontend/libs/shared/telemetry/src/lib/generated/tracked-events.ts, lines 17–22

The frontend sends an event through one method, trackEvent, and that method only accepts a name from this type, with exactly the attributes the type gives it:

trackEvent<N extends TrackedEventName>(
name: N,
attributes: TrackedEvents[N],

→ Reference: frontend/libs/shared/telemetry/src/lib/telemetry.ts, lines 71–73

So if a developer writes trackEvent('button_clicked', …) and that event is not in the plan, TypeScript stops the build. The same happens with a missing attribute, or with a value the plan does not allow, such as outcome: 'ok'. In short, an event nobody planned does not compile. To add one, you add it to the JSON first, with its question and its decision, and run the script again.

On the .NET side, a test (TrackingPlanTests) finds every metric the code creates and compares it with the plan, in both directions: a metric that is not in the plan fails, and a planned metric that nothing sends fails too. It finds them by reflection, because a counter whose name is a constant is invisible to a text search. It also refuses any attribute named like an id: in Prometheus, an id is a new series for every customer.

Writing the plan was the useful part. It found two things I did not know:

  • No cart is ever marked as abandoned. The code has an Abandoned state for a cart, and an Expired state for a stock reservation, but nothing ever moves a cart or a reservation into them: that part was never written. So, to measure abandoned carts, I count the carts that were opened and the orders that were placed, and compare the two numbers.
  • My own list had a feature the shop does not have. I had written Filters, the options beside a list of products that narrow it, such as brand or price, and asked whether people use them. The shop did not have them yet. I took that line out of the plan.

Then came the metrics themselves — ten of them, each recorded where its fact is born. Checkout is a good example. Its handler returns from eight different places: placed, declined, a price that changed, an unknown shipping option… A counter in each place is a counter that one day misses one. So the attempt is counted once, around the whole handler:

public static void Record(PlaceOrderResult result, Actor actor) =>
Attempts.Add(1,
actor.KindLabel,
actor.SurfaceLabel,
new("outcome", MetricLabels.Of(result.Outcome)),
new("discounted", result.Pricing?.Discounts.Count > 0 || result.Order?.Discounts.Count > 0));

→ Reference: src/Ordering/Features/Checkout/Checkout.cs, lines 424–431, one comment left out

actor.kind and surface say who acted and through which door: a person on the web, or an agent over MCP or UCP. The server decides them from the route, never from a header the client sends. MCP and UCP callers are anonymous by design, so the logged-in user alone would have counted every external agent as a person.

Two orders, a conversion of 0 %

This is where Grafana earns its place. The dashboard is called Shop pulse. It is a JSON file in the repository, loaded into Grafana when the container starts, and a test checks that every series it reads is one the plan produces. Nobody builds it by hand, and nobody loses it.

Grafana, at Dashboards › Tendero › Shop pulse, over the last hour of simulated traffic. Each row of the dashboard asks a question. The Commerce row, "is the shop selling, and to whom?", is folded; the Discovery row, "do searches find things?", shows a zero-result rate of 12.5 %, category browsing at 13.4 %, the agents' share of searches at 8.96 % and no degraded searches, then searches by surface and result (web, MCP, UCP) and agent tool calls by outcome.

Every row of the dashboard starts with a question, the same one the tracking plan asks. The searches here come from people on the web and from agents over MCP and UCP, and Grafana can tell them apart because every count carries who searched and through which door.

The first time I looked at it with real traffic, it said two orders placed and a checkout conversion of 0 %.

Both orders had been placed within one export interval. So the counter’s first value in Prometheus was already 2. Prometheus’s increase() needs an earlier value to measure an increase, found none, and read zero. Every new counter in a quiet shop has this problem.

OpenTelemetry sends the time each counter started. Prometheus can write a zero at that moment, if you ask it to:

.WithEnvironment("PROMETHEUS_EXTRA_ARGS", "--enable-feature=created-timestamp-zero-ingestion")

→ Reference: src/AppHost/AppHost.cs, line 335, and the test that holds it: tests/Architecture.Tests/CollectorConfigurationTests.cs, line 90

After that, the same two orders out of four attempts read 50 %. A test now fails if that line disappears.

The Commerce row of the Shop pulse in Grafana, over an hour of simulated traffic: orders per minute 0, because the one order fell a few minutes before the end of the hour; checkout conversion 100 %; carts abandoned 75.0 %; agent share of orders 0 %. Below them, checkout attempts by outcome, one placed, and carts opened by actor, all of them people.

That is the Commerce row today, with the flag in place, over an hour of simulated traffic. Four carts were opened and one became an order, so the shop sees one attempt, one order and a conversion of 100 %, and three carts in four left behind. The order is there from its first point; before the flag, a first order like this one read as nothing. Every cart was opened by a person, because no agent bought anything in that hour. The Aspire dashboard could not have shown this. It draws each counter while the app runs, but it has no query language to turn two counters into a conversion rate, and it would have forgotten the orders at the next restart.

OpenTelemetry against Faro, decided before measuring

The port made one comparison possible: the same shop, the same traffic, a different SDK in the browser. I wrote down what I expected and committed it before the Faro adapter existed. The simulator that generated the traffic is the next article.

Same seed, 40 sessions each:

OpenTelemetryFaro
Download, gzip36.4 KB67.9 KB
Telemetry sent per session3.1 KB in 2.6 requests30.1 KB in 8.4 requests
LCP / INP / CLS, p75231 ms / 24 ms / 0.167244 ms / 24 ms / 0.167
Browser traces joined to the API97.8 %97.6 %

LCP, INP and CLS are Google’s Core Web Vitals: how long the main content takes to appear, how fast the page answers a tap or a click, and how much the layout jumps while it loads. p75 is the value that three sessions in four stay under.

Faro gives more without being asked — a session, page views, its own events — and pays for it in size. I had predicted it would be at least twice as large. It was 1.87 times. Wrong, by a little, and the prediction stays in the file as it was written.

The real finding was not in the table. Faro sends what people type. Its own events describe every request the page makes, including the full URL, and the search URL contains the search. In one run, 170 lines in Loki held things like q=lampara que no ocupe sitio en la mesa. This is technical telemetry, so the consent the shop asks for does not stop it. The OpenTelemetry adapter had removed query strings and ids from URLs since the first day; Faro, as it comes, does not.

The Faro adapter now does the same, in one hook that runs before anything is sent (faro-adapter.ts, line 82). That hook took our adapter from 106 lines to 138 — one more than the OpenTelemetry one. OpenTelemetry stays the default. Faro stays one setting away, which is what a port is for.

What it does not do yet

  • Product analytics — funnels stored for months, people against agents — waits for phase 11, where the agent checkout exists. This work sends the events; it does not keep them as analytics. (Phase 11 has been built since: the backoffice’s own dashboard shows people against agents, read from the shop’s database rather than from this stack, which is behind a switch.)
  • Behavioural events in the browser wait for the shopper’s consent, which the storefront asks for once, with yes and no the same size.
  • The Grafana mode is for a developer’s machine: one container, nothing sized for production. A deployed Tendero would run the same pieces as separate services, as On your machine, and everywhere else explains.

The repository is public and MIT licensed, and the code exactly as this article describes it is tagged blog/05-seeing-it-run. The plan and its generated document are in docs/telemetry/, and the comparison, expectations first, in docs/analysis/otel-vs-faro.md.

How we know it works

  • The test that fails: TrackingPlanTests was broken on purpose five ways — an unplanned counter, a wrong unit, a live metric marked planned, an attribute named product_id, an unplanned browser event — and each one turned it red, naming what it found. The last one also fails the TypeScript build.
  • The check on every pull request: the plan’s generated files are generated again and compared in CI, the Collector’s configuration and the Prometheus flag have their own tests, and both browser adapters pass one contract suite.
  • The mutation score: not at this tag. Phase 9b closed the same day, just after it, with a Stryker run over every project it touched, about an hour of machine time; the traffic simulator’s first baseline came out at 87.66 % of covered. How to read that number is article 04.

Next article: A shop with no customers — a dashboard over no traffic is a flat line, so where did the forty sessions in that table come from?

Tendero — commerce for humans and agents.

Deja una respuesta

Este sitio utiliza Akismet para reducir el spam. Conoce cómo se procesan los datos de tus comentarios.