A beginner-friendly sample for a conference talk on OpenTelemetry in .NET. You launch Star Trek starship missions from a web dashboard; every launch produces a distributed trace, a custom metric, structured logs, and propagated baggage — all visible in the .NET Aspire dashboard.
Stack: .NET 10 + Aspire 13.5 + EF Core (in-memory SQLite) + OpenTelemetry.
A launch flows Web → API → database as a single trace and demonstrates:
- Traces / spans — automatic ASP.NET Core + HttpClient + EF Core spans, plus a manual
LaunchMissionspan and a reusabledb.launch.insertspan. - Metrics — a custom counter
missions_launched, tagged by ship name (outcome is counted separately bymission.completed, once it is actually known). - Structured logs —
ILoggermessages that automatically carryTraceId/SpanId. - Baggage —
mission.commanderandmission.priorityset in the Web tier and propagated to the API over the W3Cbaggageheader, with zero manual header plumbing. The commander is read back out of ambient baggage on the background flight thread and tagged onto everymission.tickspan — it is never passed as a method parameter. - Errors — the "Force failure" checkbox biases a flight toward a fast shield collapse, which
produces an error span (with a recorded exception event) plus a matching Error-level log,
both carrying the same
TraceId. - GenAI telemetry — "Request Debrief" asks a model to narrate the finished flight, producing a
mission.debriefspan with achat {model}child carrying the OpenTelemetrygen_ai.*semantic-convention attributes, token-usage metrics, and a correlated log. No API key is needed — a simulatedIChatClientships in the box; see AI debriefs below.
- .NET 10 SDK
- .NET Aspire 13.5 — the Aspire CLI and/or workload (
aspire --versionshould report 13.5 or later)
No Docker needed: the API uses an in-memory SQLite database, so nothing persists between runs — every start begins with a clean roster.
From the solution root:
# Preferred (Aspire CLI):
aspire run
# Or with the SDK directly:
dotnet run --project MissionControl.AppHostThe Aspire dashboard opens automatically. From the dashboard:
- Open the missioncontrol-web endpoint — the Mission Control dashboard.
- Pick a ship, set a commander + priority, and click Launch. The ship enters In Flight and cannot launch again until the mission ends.
- Watch the Live Telemetry panel: every 2 seconds a random event changes the ship's warp speed, shield strength, and photon torpedoes — each tick recorded as a trace span and metric sample.
- Missions end on their own:
- Shields reach 0 → Failure (the mission span is marked as an error, with a matching Error-level log).
- Torpedoes reach 0 → Retreat.
- Otherwise, after a random 20–40s → Success.
- Toggle Force failure to bias a launch toward a fast shield collapse.
- Use Generate Load to launch every docked ship at once (fills Traces & Metrics for ~20–40s), Reset All to abort flights and re-dock, and Inspect Baggage to see the key-value context the API received via header propagation.
Select a ship that has already flown and click Request Debrief. The API builds a prompt from
that flight's telemetry, sends it through an IChatClient, and returns a three-sentence debrief
along with its token counts.
It works with no API key. The default inner client is SimulatedChatClient, which writes the
debrief locally, takes a realistic 400–1200 ms, and reports UsageDetails so the token metrics are
real numbers rather than zeros. The point of the module is that the OpenTelemetry wrapper
instruments the IChatClient interface, not any particular provider — so the spans and metrics
are identical whether the model is simulated, OpenAI, or Azure OpenAI.
To use a real model instead, nothing changes but configuration:
dotnet user-secrets set "OpenAI:ApiKey" "sk-..." --project MissionControl.ApiOptionally set OpenAI:Model (default gpt-4o-mini). OpenAI:CaptureContent (default false)
turns on prompt/completion capture — leave it off unless you know the content is safe to store.
What to point at in the dashboard:
| Signal | What appears |
|---|---|
| Trace | mission.debrief (why the model was called, which ship) with a chat {model} child carrying gen_ai.operation.name, gen_ai.provider.name, gen_ai.request.model, gen_ai.response.model, gen_ai.response.finish_reasons, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens. |
| Metrics | gen_ai.client.token.usage and gen_ai.client.operation.duration from the wrapper, plus mission.debrief.tokens (by ship and gen_ai.token.type) and mission.debrief.requests (by outcome). |
| Logs | "Mission debrief generated for {Ship}…" with model and token counts — and deliberately without the prompt or the completion. |
A run order that avoids the two things that make the demo look flat:
- Launch one ship (Enterprise), then immediately hit Generate Load. A single launch puts
one point on the
missions_launchedchart, which reads as a broken dashboard; five in-flight ships give the Metrics tab real shape for the next 20–40 seconds. - Go to Structured Logs and find
Mission launch requested: Enterprise…. Point at theTraceIdon the record — that is the slide-7 payoff. - Click through to the trace. Give it ~30 seconds first. Spans export when they end:
mission.tickchildren appear every 2s, but theMission: {ship}parent bar does not render until the flight finishes. Click too early and the waterfall looks half-built. - In the waterfall, the flight is nested under the launch request —
POST→HTTP POST→POST→LaunchMission→Mission: {ship}→mission.tick×N. SameTraceIdthroughout. Say "nested under the launch," not "at the top." - For baggage, open a
mission.tickspan and show themission.commandertag, then hit Inspect Baggage to show the raw key-value pairs the API received over the header. The tag is read from ambient baggage on a background thread — never passed as a parameter. - Metrics tab:
missions_launchedgrouped bymission.name, then the live gauges (mission.shield_strengthis the most legible) andmission.completedbyoutcome. - Optional error beat: launch with Force failure checked and come back to a red error span
plus its matching Error-level log, both on the same
TraceId. - GenAI beat: select a ship that has finished flying and click Request Debrief. In the trace,
mission.debriefwraps achat {model}child whose attributes are thegen_ai.*semantic conventions; in Metrics,gen_ai.client.token.usageandmission.debrief.tokensnow have data. Note out loud that the log line carries the token counts but not the prompt.
| Dashboard tab | What to point at |
|---|---|
| Traces | The launch trace (missioncontrol-web → missioncontrol-api → SQLite) containing the manual LaunchMission span, and nested under it a long-lived Mission: {ship} span with a mission.tick child every 2s carrying warp/shields/torpedoes tags. The flight is part of the launch's trace, not a separate root — same TraceId for all 20–40s, which is what makes "click the launch log line, land in the waterfall" work. A mission that ends in Failure shows a red error span with an exception event. Note: spans export when they end, so ticks appear every 2s but the Mission: {ship} bar only fills in once the flight finishes. |
| Metrics | missions_launched (counter), the live gauges mission.warp_speed, mission.shield_strength, mission.photon_torpedoes, and the counters mission.events (by event) and mission.completed (by outcome). Group any of them by the mission.name tag. |
| Structured logs | The per-tick "Mission {Ship}: {Event}…" lines and the "Mission {Ship} ended: {Outcome}…" entries, each linked to its trace via TraceId/SpanId. |
| Resources | Both projects, with health status and endpoints. |
| Talk slide / concept | File | What to show |
|---|---|---|
| The OTel hub (register everything once) | MissionControl.ServiceDefaults/Extensions.cs → ConfigureOpenTelemetry |
WithTracing(... AddSource("MissionControl.Telemetry")), WithMetrics(... AddMeter("MissionControl.Telemetry")), log pipeline, and OTLP export gated on OTEL_EXPORTER_OTLP_ENDPOINT. |
| Shared ActivitySource + Meter | MissionControl.ServiceDefaults/MissionTelemetry.cs |
Well-known name MissionControl.Telemetry used by both signals. |
| Reusable span factory (advanced) | Defined: MissionControl.ServiceDefaults/MissionTelemetry.cs → StartDatabaseSpan<T>Used: MissionControl.Api/Simulation/MissionSimulator.cs → PersistResultAsync |
Starts an internal span and auto-tags entity properties as db.entity.* via safe reflection. Call site is one line; look for the db.launch.insert span at the end of each flight, tagged db.entity.commander, db.entity.success, etc. |
| Custom metric counter (advanced) | MissionControl.ServiceDefaults/MissionTelemetry.cs → MissionMetrics + AddMissionMetrics() |
Counter<long> named missions_launched created via IMeterFactory, wrapped in a DI singleton. |
| Manual span + tags | MissionControl.Api/Program.cs → launch endpoint |
ActivitySource.StartActivity("LaunchMission", ...) and SetTag(...). |
| Continuous telemetry (2s ticks) | MissionControl.Api/Simulation/MissionSimulator.cs |
Long-lived Mission: {ship} span (nested in the launch trace) + per-tick child spans, live gauges (mission.warp_speed/shield_strength/photon_torpedoes), event/outcome counters, and per-tick structured logs at Information — plus a Warning when shields drop below 25%, so severity filtering in the dashboard has something to find. End conditions set Success / Failure / Retreat. |
| GenAI: instrument the interface | MissionControl.Api/Program.cs → IChatClient registration |
new ChatClientBuilder(inner).UseOpenTelemetry(sourceName: MissionTelemetry.ChatSourceName, ...). Wraps the interface, so simulated and real providers emit identical telemetry. |
| GenAI: the privacy switch | same registration → EnableSensitiveData |
Off by default. Prompts and completions are the likeliest place for PII to reach your observability vendor. |
| GenAI: business span + cost metric | MissionControl.Api/Ai/MissionDebriefService.cs |
mission.debrief parent span (why the call happened) and the mission.debrief.tokens counter (what it cost), alongside the wrapper's automatic gen_ai.* signals. |
| GenAI: no key required | MissionControl.Api/Ai/SimulatedChatClient.cs |
Reports UsageDetails and ChatClientMetadata so token counts, provider name, and model land on the span exactly as a real client's would. |
| Baggage — set | MissionControl.Web/Program.cs → launch proxy |
Baggage.SetBaggage("mission.commander", ...) before the HttpClient call. |
| Baggage — read + propagation | MissionControl.Api/Program.cs + GET /api/baggage |
Baggage.GetBaggage("mission.commander") — arrives via the baggage header automatically. The Inspect Baggage button in the UI shows exactly what the API received; that is the visible proof, since OTel does not copy baggage onto span attributes. |
| Baggage — survives onto a background thread | MissionControl.Api/Simulation/MissionSimulator.cs → RunAsync |
Baggage.GetBaggage("mission.commander") read on the flight thread, then tagged onto every mission.tick. Task.Run captures the ExecutionContext, so baggage set in the Web tier is still ambient here long after the HTTP request ended. |
| Structured logging w/ trace correlation | MissionControl.Api/Program.cs → launch endpoint |
logger.LogInformation("Mission launch requested: {MissionName}, commander {Commander}, priority {Priority}, status {Status}", ...) — this is the record shown on the "Anatomy of an OTel Log Record" slide. |
| Error spans + error logs | MissionControl.Api/Simulation/MissionSimulator.cs → end-of-flight Failure branch |
root.SetStatus(ActivityStatusCode.Error, ...), root.AddException(breach), and the matching _logger.LogError(breach, ...). Reached fastest via the Force failure checkbox. |
| Orchestration + wiring | MissionControl.AppHost/Program.cs |
AddProject<Projects.MissionControl_Api>(), references + service discovery. |
MissionControlDemo/
├─ MissionControlDemo.sln
├─ Directory.Packages.props # central package versions (.NET 10 / Aspire 13.5)
├─ MissionControl.AppHost/ # Aspire orchestrator: API + Web
├─ MissionControl.ServiceDefaults/ # OTel config + custom telemetry (the talk's core)
├─ MissionControl.Api/ # Minimal API + EF Core (in-memory SQLite) + seeded roster
│ └─ Ai/ # IChatClient debrief service + simulated client (GenAI telemetry)
└─ MissionControl.Web/ # Static dashboard + baggage-setting proxy
GET /api/missions— the rosterGET /api/missions/{id}/telemetry— live flight snapshot (warp / shields / torpedoes / status), polled every 2s by the Live Telemetry panelGET /api/missions/{id}/launches— launch history + aggregate stats for one missionPOST /api/missions/{id}/launch— body{ "commander", "priority", "forceFailure" }; starts a background flight. Returns409if the ship is already on a mission.POST /api/missions/reset— abort any flights and return every ship to Docked (Reset All)POST /api/missions/{id}/debrief— AI mission debrief for the last flight; returns the narrative plus token countsGET /api/baggage— echoes current baggage (propagation demo / Baggage Inspector)
- The database schema is created with
EnsureCreated()and seeded on startup — no migrations needed to run the demo. - OTLP export turns on only when
OTEL_EXPORTER_OTLP_ENDPOINTis present; Aspire injects it, so signals reach the dashboard automatically with no extra config.