Skip to content

SLICE-1.1 Telemetry Deep Dive - From App Scenario to .NET Techniques

This is the companion article for SLICE-1.1 Design Notes. The design note is the compact reference map. This file is the slower walkthrough: what happens in the app, how to configure it, why the implementation is shaped this way, and which C#/.NET techniques are doing the important work.

The goal is not only to understand "where the telemetry code is." The goal is to understand the habit behind it:

  • hot producer work stays small and isolated
  • app-facing state is published as stable snapshots
  • backpressure policy is explicit and observable
  • shutdown is cooperative and repeatable
  • configuration fails fast when it is unsafe or incomplete

Those ideas matter beyond this simulator. They are the same ideas you would use when replacing this code with a real PLC, OPC UA, SECS/GEM, vendor SDK, or data-acquisition driver.

The User Scenario

Imagine the app is opened by an engineer who wants to see a richer machine simulation. They are not starting an inspection run yet. They just want the desktop to behave like a live wafer inspection machine with many process values changing in the background.

At startup, the app loads the Simulator section from src/InspectionPrototype.App/appsettings.json. That section has two different pieces that are easy to confuse:

  • Simulator:Profiles controls app-level simulator behavior, including how often a telemetry snapshot is published into AppState.
  • Simulator:Tags controls the actual tag registry: names, units, per-tag emit intervals, and noise models.

The app then starts its background services. One of those services, TagStreamPipelineService, depends on ITagStream, so DI constructs SimulatedTagSource. That constructor starts the telemetry producer loops.

This is the first important mental model:

Telemetry is app-lifetime data, not run-lifetime data. The source starts during host startup and keeps producing while the app is open. It does not wait for an inspection run to begin.

In the UI, the operator can pick a simulator profile from the "Simulator Profile" panel and press Apply. Selecting MultiTag changes the snapshot interval to 50 ms, so the UI and canonical state receive snapshots at about 20 Hz. Selecting Normal uses 200 ms, or about 5 Hz. The individual tags still keep their configured IntervalMs values from Simulator:Tags; only the snapshot publish rate changes.

Profile changes are rejected while the workflow is active. In practical terms:

  • choose MultiTag while the app is idle if you want the fast snapshot profile
  • start a run after the profile is selected
  • do not expect profile changes during a running workflow to take effect

The current UI readout only displays two reserved tags:

  • temperature.celsius
  • pressure.bar

The other tags are still being generated, snapshotted, counted, and stored in AppState.LatestTagValues. They just are not all rendered as visible text yet.

How To Configure It

There are two common changes people want to make: changing how often snapshots reach the app, and changing which tags exist.

Change The Snapshot Rate

Edit a profile in Simulator:Profiles:

json
{
  "Name": "MultiTag",
  "MotionSpeedUnitsPerSecond": 20.0,
  "TelemetryIntervalMs": 50,
  "FrameIntervalMs": 500,
  "DefectProbabilityPerFrame": 0.05,
  "ConnectionFailureProbability": 0.2,
  "EncoderIntervalMs": 5
}

TelemetryIntervalMs is the snapshot cadence. With 50, the snapshot publisher tries to publish one TagSnapshot every 50 ms. With 200, it publishes every 200 ms.

This setting does not mean every tag emits every 50 ms. It means the producer freezes whatever the latest value is for each tag every 50 ms and sends that frozen map to the consumer.

Change The Tags

Edit Simulator:Tags:

json
{
  "Name": "vacuum.chamber.pressure.bar",
  "Unit": "bar",
  "IntervalMs": 50,
  "Noise": {
    "Kind": "Sine",
    "Baseline": 1.0e-6,
    "Amplitude": 5.0e-8,
    "PeriodMs": 30000
  }
}

Each tag has:

  • Name: the stable key used in dictionaries, metrics, and UI lookups
  • Unit: display/engineering metadata
  • IntervalMs: how often that tag's own emitter tries to produce a sample
  • Noise: how the simulator calculates values over time

The validator enforces the guardrails:

  • names cannot be empty
  • names must be unique
  • IntervalMs must be between 2 and 1000
  • Noise.Kind must be Sine, Drift, RandomWalk, or Step
  • required noise parameters must be present

Keep temperature.celsius and pressure.bar unless you also update the UI projection and tests. Those names are reserved because MainViewModel reads them directly for the current telemetry text. The seed configuration and spec keep them present, but the current validator does not enforce those two names by itself.

Tag definitions are built once when SimulatedTagSource is constructed. If you change the tag list in appsettings.json, restart the app. Runtime profile selection changes the snapshot rate, but it does not rebuild the tag registry or per-tag emit intervals.

A Sample's Journey Through The App

Let us follow one value, temperature.celsius, from configuration to the text on the screen.

First, configuration binding creates mutable options objects:

  • SimulatorTagsOptions
  • SimulatorTagOptions
  • NoiseOptions

Options classes are intentionally plain mutable objects because configuration binding needs to set their properties. They are not the runtime domain model.

During SimulatedTagSource construction, each options entry is converted into a domain record:

  • TagDefinition
  • one NoiseModel variant, such as SineNoise

That conversion step matters. The runtime code does not keep asking "which JSON fields were present?" It works with a typed model.

Then the source starts one task per tag. The temperature.celsius emitter owns:

  • its own Random instance
  • its own double state for stateful noise, such as random walk
  • its own fixed delay interval from TagDefinition.IntervalMs

The loop is simple:

  1. Wait for the tag interval with Task.Delay(interval, ct).
  2. Get the current UTC time.
  3. Evaluate the noise model.
  4. Optionally skip the publish if TelemetryDropoutChance says to simulate dropout.
  5. Create a TagSample.
  6. Write that sample into _latestValues["temperature.celsius"].
  7. Increment samples.ingested{tag.name="temperature.celsius"}.

The producer does not push every sample directly to AppState. Instead, it writes the latest value into a per-tag cell held by a ConcurrentDictionary<string, TagSample>.

That cell-store is like a live instrument panel inside the producer. Each tag owns its own little box. A high-rate tag may replace its own box many times before the app takes the next snapshot. That is expected.

A separate publisher task wakes up at the active profile's TelemetryIntervalMs. It copies the current cells into an ImmutableDictionary<string, TagSample> and wraps that in a TagSnapshot.

Then it writes the snapshot into a bounded channel:

csharp
Channel<TagSnapshot> capacity = 1
FullMode = DropOldest

That channel has one job: keep the newest complete telemetry picture available to the consumer. If the consumer is late, the stale snapshot is replaced. Operator telemetry cares more about freshness than replaying old readings.

TagStreamPipelineService is the only consumer. It uses:

csharp
await foreach (var snapshot in _source.Reader.ReadAllAsync(stoppingToken))

For every snapshot, it calls:

csharp
_store.Update(s => s with { LatestTagValues = snapshot.Values });

Now AppState.LatestTagValues points to an immutable map. The UI can read it without touching the producer's hot mutable dictionary.

Finally, MainViewModel receives IAppStateStore.StateChanged, dispatches to the WPF UI thread, and projects the two reserved tags into TelemetryText:

text
Temp: 25.3 deg C   Pressure: 1.013 bar

That is the full path:

text
appsettings.json
  -> options validation
  -> TagDefinition records
  -> per-tag emitter task
  -> ConcurrentDictionary latest-value cell
  -> immutable TagSnapshot
  -> bounded Channel<TagSnapshot>
  -> TagStreamPipelineService
  -> AppState.LatestTagValues
  -> MainViewModel.TelemetryText
  -> WPF TextBlock

The Two Rates

Most confusion in this slice comes from mixing up two rates.

The first rate is per-tag emit cadence. It comes from each tag's IntervalMs. For example, a vibration tag might be configured for 2 ms while an ambient temperature tag is configured for 1000 ms.

The second rate is snapshot cadence. It comes from the active simulator profile's TelemetryIntervalMs.

These are intentionally separate. A real machine does not make every signal update at the same frequency. Vibration, pressure, chamber temperature, valve state, and process time all have different natural cadences.

The app does not need to write AppState for every individual sample. It needs a fresh, coherent snapshot often enough for operator workflow, command guards, diagnostics, and summary capture.

So the implementation lets tags emit independently, then samples the latest values as a group.

The Two Kinds Of Coalescing

There are also two different coalescing counters.

samples.coalesced{tag.name} means a tag emitter overwrote its own latest-value cell before the snapshot publisher copied it. This is normal for fast tags. It does not mean the app is unhealthy.

telemetry.coalesced means the snapshot channel already had an unread snapshot when the publisher produced another one. That means the consumer was behind. This should be near zero in healthy operation.

In plain language:

  • per-tag coalescing means "this tag is faster than the snapshot cadence"
  • telemetry coalescing means "the app is slower than the snapshot cadence"

The first is a modeling trade-off. The second is a backpressure signal.

.NET Technique: Channel As A Freshness Boundary

System.Threading.Channels gives the producer and consumer a clear contract. It is an async producer-consumer primitive: one side writes values, the other side reads them, and the channel owns the waiting, wake-up, completion, and backpressure mechanics.

That is a good fit for telemetry because the source and the app state pipeline are different pieces of runtime work:

text
SimulatedTagSource
  produces snapshots on its own schedule

TagStreamPipelineService
  consumes snapshots when the host has started the background service
  writes the latest snapshot into AppState

Without a channel, the producer would need to call the consumer directly, raise events, or share a queue. Each of those choices has a cost:

  • direct calls couple the simulator to AppStateStore
  • events make backpressure and shutdown harder to reason about
  • ConcurrentQueue<T> is thread-safe but does not define capacity, drop policy, or completion by itself

A channel gives this boundary a first-class shape.

This implementation uses a bounded channel with capacity 1 and DropOldest. That says:

  • do not grow memory without limit
  • do not block the producer waiting for the UI/state pipeline
  • do keep the newest complete snapshot
  • do make lag measurable

Capacity 1 is the key design choice. The channel is not a history buffer. It is a "latest snapshot handoff" slot:

text
empty channel
  -> producer writes snapshot A

consumer reads A before the next publish
  -> normal case

consumer is late and snapshot A is still in the channel
  -> producer writes snapshot B
  -> DropOldest removes A
  -> B becomes the only queued snapshot

That behavior matches operator telemetry. If the UI/state pipeline briefly falls behind, showing a 300 ms old temperature snapshot before a fresh one is not useful. The operator cares about the freshest current machine picture. Historical telemetry would need a separate persistence pipeline with its own retention policy.

This is a better fit than ConcurrentQueue<TagSnapshot> because a queue does not give you a complete backpressure story by itself. A queue can grow forever unless you build capacity and drop behavior around it. The channel has that policy built in.

The producer uses TryWrite, not WriteAsync, because it does not want to wait. With DropOldest, the expected behavior under lag is replacement, not producer slowdown. That matters in this app because the source is a simulator for machine-side data. A slow UI update should not change the simulated machine's notion of "current values."

The consumer uses ReadAllAsync(stoppingToken) because it reads like the lifecycle: keep consuming until the channel completes or the host asks the service to stop. It also avoids a polling loop. The consumer sleeps efficiently when there is no snapshot, wakes when one arrives, and exits cleanly when cancellation or channel completion occurs.

The channel also narrows ownership:

  • the producer owns the ChannelWriter<TagSnapshot>
  • the application service only receives a ChannelReader<TagSnapshot>
  • callers through ITagStream cannot accidentally publish into the stream

This is why ITagStream.Reader exposes only the reader side. It preserves the single producer/single consumer assumption and makes the stream easier to replace later with a real driver-backed implementation.

.NET Technique: BackgroundService As The Consumer Owner

TagStreamPipelineService inherits from BackgroundService. That is the right shape for a long-running app pipeline:

  • the generic host starts it
  • the generic host gives it a shutdown token
  • the service owns the read loop
  • normal cancellation is handled as normal shutdown

The service does not know how samples are simulated. It only knows the ITagStream contract:

  • here is a ChannelReader<TagSnapshot>
  • here is the current snapshot coalesce count
  • here are tag definitions if a reader needs metadata

That boundary is important. A future real telemetry source can implement ITagStream without changing the application service that writes to AppState.

.NET Technique: ConcurrentDictionary For Hot Mutable Cells

The producer has many writers:

  • one emitter task per tag
  • each task writes its own tag key
  • one publisher task enumerates all latest values

A normal Dictionary<string, TagSample> would not be safe here. The producer uses ConcurrentDictionary<string, TagSample> because the writes and reads happen from different tasks.

The important detail is that the dictionary is not the public app state. It is an internal hot structure. The app never binds the UI to it. The publisher copies it into an immutable map before crossing the application boundary.

That split is the practice worth remembering:

  • use concurrent mutable collections inside the component that owns hot writes
  • publish immutable snapshots to the rest of the app

AddOrUpdate also gives the code a natural place to count per-tag overwrites. If the key already existed, the emitter replaced a prior value that had not necessarily been snapshotted yet, so samples.coalesced is incremented.

.NET Technique: ImmutableDictionary For Published State

AppState is a record that represents the canonical operator-facing state. It should not expose a mutable dictionary that another task can keep changing under readers.

That is why TagSnapshot.Values is an ImmutableDictionary<string, TagSample>.

Every snapshot is a stable value. Once it is in AppState, readers can treat it as a point-in-time view. The next snapshot replaces the map with a new one.

There is an allocation cost to this. In SLICE-1.1 it is acceptable because there are 50 tags and the snapshot rate is modest. If this grows to thousands of tags or much higher snapshot rates, this is exactly the place to measure before changing the design.

.NET Technique: Records For Small Value Models

The slice uses C# records for telemetry data:

  • TagDefinition
  • TagSample
  • TagSnapshot
  • SimulatorProfile
  • NoiseModel variants

Records make sense here because these objects are values in the domain. A sample is "tag name, timestamp, value, quality." A snapshot is "capture time and values." They are not identity-heavy service objects.

This also pairs well with immutable app state:

csharp
s with { LatestTagValues = snapshot.Values }

The with expression creates a new AppState value based on the previous one, with only the telemetry map changed.

.NET Technique: A Sealed Record Hierarchy For Noise Models

The noise system is modeled as:

  • abstract NoiseModel
  • sealed SineNoise
  • sealed DriftNoise
  • sealed RandomWalkNoise
  • sealed StepNoise

Then NoiseModelEvaluator uses pattern matching:

csharp
return model switch
{
    SineNoise s => ...,
    DriftNoise d => ...,
    RandomWalkNoise rw => ...,
    StepNoise sn => ...,
    _ => throw ...
};

This is a practical C# way to model a small discriminated union. It keeps each variant explicit without spreading string checks through the runtime path. Strings are accepted at the configuration boundary, validated, and converted into typed models.

.NET Technique: Caller-Owned State And ref

RandomWalkNoise needs memory. Its next value depends on its previous value.

Instead of hiding state inside NoiseModelEvaluator, the evaluator takes:

csharp
ref double state

The emitter owns that state variable. That keeps the evaluator pure-ish and testable: given a model, timestamp, RNG, and caller-owned state, it returns the next value and updates the state when needed.

This is a nice pattern when one algorithm needs a small piece of mutable state but you do not want to make the algorithm itself a long-lived object.

.NET Technique: CancellationToken For Cooperative Shutdown

There are two cancellation stories in this flow.

The consumer has a host-owned token. TagStreamPipelineService is a BackgroundService, so the Generic Host passes a stoppingToken into ExecuteAsync. The service uses that token when it reads the channel:

csharp
await foreach (var snapshot in _source.Reader.ReadAllAsync(stoppingToken))

When the host stops, that token is canceled. The channel read wakes up, throws OperationCanceledException, and the service treats it as normal shutdown.

The producer has its own token. SimulatedTagSource is not a hosted service; it is a singleton that starts emitter loops in its constructor. Because the host does not call StopAsync on it, the source owns a private CancellationTokenSource called _cts. Every emitter loop and the snapshot publisher receive _cts.Token.

The loop shape is:

csharp
while (!ct.IsCancellationRequested)
{
    try
    {
        await Task.Delay(interval, ct);
    }
    catch (OperationCanceledException)
    {
        break;
    }

    // produce work
}

The token does not kill the task. It gives Task.Delay a way to wake up early after the source requests shutdown. The loop observes cancellation and exits at a safe point.

The full graceful app shutdown flow is:

text
User clicks the window X, presses Alt+F4, or closes the last WPF window
  -> WPF begins application shutdown
  -> App.OnExit(...)
  -> _host.StopAsync(TimeSpan.FromSeconds(5))
       cancels hosted-service stopping tokens
       TagStreamPipelineService exits its ReadAllAsync loop
  -> _host.Dispose()
       disposes DI-owned singleton services
       SimulatedTagSource.Dispose() runs
       _cts.Cancel() wakes producer Task.Delay calls
       producer loops exit at cancellation boundaries
       channel writer is completed

So cancellation is cooperative in two places:

  • the hosted consumer cooperates with the Generic Host's stoppingToken
  • the producer loops cooperate with SimulatedTagSource's private _cts.Token

This is cooperative cancellation: request stop, let the code reach a known boundary, then exit cleanly.

.NET Technique: IDisposable And Idempotent Cleanup

SimulatedTagSource implements IDisposable because it owns runtime resources that need a clear stop signal when the app shuts down:

  • a CancellationTokenSource
  • producer loops that should observe cancellation and exit
  • a channel writer that should complete

The Dispose method starts with:

csharp
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;

Read that line as: "only the first caller gets to run cleanup."

_disposed is an int used like a boolean:

  • 0 means cleanup has not run yet
  • 1 means cleanup has already started or finished

Interlocked.Exchange(ref _disposed, 1) atomically reads the old value and sets the field to 1 in one operation. The old value is what the expression returns.

On the first Dispose call:

text
old value = 0
_disposed becomes 1
0 != 0 is false
cleanup continues

On the second Dispose call:

text
old value = 1
_disposed stays 1
1 != 0 is true
return immediately

The atomic part matters. A normal check like this is not thread-safe:

csharp
if (_disposed != 0) return;
_disposed = 1;

Two threads could both see _disposed == 0 and both run cleanup. Interlocked.Exchange makes the check-and-set one indivisible operation, so only one caller can win.

That guard makes cleanup idempotent: the first call runs the shutdown path, and any later call returns immediately. In this source, Dispose is what triggers producer cancellation:

text
_cts.Cancel()
  -> emitter loops and the snapshot publisher wake from Task.Delay(...)
  -> loops exit at a cooperative cancellation boundary

_channel.Writer.TryComplete()
  -> no more snapshots should be published

This became important because the current DI registration aliases the same singleton under two service types:

csharp
services.AddSingleton<SimulatedTagSource>();
services.AddSingleton<ITagStream>(sp => sp.GetRequiredService<SimulatedTagSource>());

That factory does not create two producers. It returns the same SimulatedTagSource instance for the ITagStream registration. The surprise is disposal: the container can track both registrations as disposable and call Dispose twice on the same object at host teardown. Without the Interlocked.Exchange guard, the second call can touch an already disposed CancellationTokenSource.

There is no current production consumer that needs the concrete SimulatedTagSource from DI. TagStreamPipelineService depends on ITagStream. So the simpler registration would usually be enough:

csharp
services.AddSingleton<ITagStream, SimulatedTagSource>();

That is the better default practice: register the smallest contract consumers need. Register both concrete and interface only when the concrete type has a real second role, such as when the same object must also be registered as an IHostedService, or when another service truly needs concrete-only members.

The source is not an IHostedService, so _host.StopAsync(...) does not directly stop it. StopAsync stops the hosted consumers. The source stops later, when _host.Dispose() disposes DI singletons and calls SimulatedTagSource.Dispose().

Clicking the window X is graceful in the normal case because WPF's default shutdown mode is OnLastWindowClose. If a future Window.Closing handler cancels the close, then shutdown does not happen and App.OnExit is not reached.

Disposal is not guaranteed for non-graceful termination:

  • Task Manager kill or taskkill /F
  • power loss, OS crash, or hard reboot
  • debugger Stop
  • Environment.FailFast(...)
  • native crash or fatal process termination
  • Environment.Exit(...) before host cleanup runs
  • fatal unhandled exception where the process tears down before normal WPF shutdown

This is idempotent cleanup. It is one of those small production details that prevents an app from crashing while it is already trying to shut down. It is still worth keeping even if the DI registration is simplified, because cleanup methods should be safe to call more than once.

.NET Technique: Options Validation As A Startup Safety Gate

The tag configuration is validated with IValidateOptions<SimulatorTagsOptions> and ValidateOnStart().

That means a bad tag config fails at startup instead of becoming a half-working runtime simulation. For this kind of system, fail-fast configuration is kind. It tells the developer exactly what is wrong before the app starts producing misleading telemetry.

The runtime conversion also uses nullable option fields intentionally. NoiseOptions is a flat bag because JSON binding is simple that way. The validator proves the needed fields are present. Then BuildTagDefinition can use those values to create a typed noise model.

.NET Technique: Metrics With Dimensions

AppMetrics uses System.Diagnostics.Metrics.

There are two useful patterns here:

  • counters for event rates
  • an observable gauge for current active tag count

Per-tag counters include a dimension:

csharp
new KeyValuePair<string, object?>("tag.name", def.Name)

That lets captures answer questions like:

  • which tags are emitting?
  • which tags are coalescing?
  • are slow tags near their expected rate?
  • are high-rate tags limited by Windows timer resolution?

The tags.active gauge is not a counter because active tag count is not an event rate. It is a current property of the running source.

Two thread-safety details are worth calling out.

First, _snapshotCoalescedCount is incremented only by the snapshot publisher task, but it is read by TagStreamPipelineService through ITagStream.SnapshotCoalescedCount. So the counter has one writer and at least one external reader:

text
SimulatedTagSource.PublishSnapshotsAsync
  -> Interlocked.Increment(ref _snapshotCoalescedCount)

TagStreamPipelineService.ExecuteAsync
  -> reads _source.SnapshotCoalescedCount
  -> property uses Interlocked.Read(ref _snapshotCoalescedCount)

Interlocked is not needed here because multiple publisher tasks are racing; there is only one publisher. It is used because the value crosses a task/thread boundary. Interlocked.Increment and Interlocked.Read make the shared counter access atomic and make the intent obvious: this field is shared runtime state.

Second, metric counters are already designed for concurrent calls. Every tag emitter can call this at the same time:

csharp
_metrics.SamplesIngested.Add(
    1,
    new KeyValuePair<string, object?>("tag.name", def.Name));

No app-level lock is needed around Counter<T>.Add(...). System.Diagnostics.Metrics expects instrumentation to be called from concurrent request handlers, background tasks, callbacks, and producer loops. The thing to watch is not thread safety; it is cardinality. A tag.name dimension creates one metric series per tag. With 50 seeded tags this is fine. With thousands of dynamic names, it would need a different observability plan.

.NET Technique: WPF Dispatcher At The UI Boundary

AppStateStore.StateChanged can be raised by background services. WPF UI properties must be updated on the UI thread.

MainViewModel handles that boundary:

csharp
_dispatcher.Invoke(() => Project(state));

This keeps background services free of WPF references. They update canonical state. The view model owns UI-thread projection.

That separation is why the telemetry pipeline can stay an application-layer service instead of becoming UI code.

Why The Implementation Is Strong

This slice is good because it does not pretend the world is slower or simpler than it is. It accepts that producers and consumers run at different speeds, then makes that fact explicit.

The strongest choices are:

  • per-tag emitters model different real signal cadences
  • the latest-value cell-store avoids pushing every high-rate sample into app state
  • immutable snapshots give readers a stable view
  • the bounded channel prevents stale backlog and unbounded memory growth
  • coalescing metrics distinguish expected aliasing from real consumer lag
  • options validation catches bad configs before runtime
  • shutdown is cancellation-aware and double-dispose safe

The measurement story also matters. The docs and captures acknowledge timer-resolution limits on Windows instead of hiding them. That is a healthy engineering habit: measure, document the real behavior, then gate only what the platform can honestly guarantee.

How To Extend It Safely

If you add a tag, start in Simulator:Tags.

Make sure the name is unique, choose the slowest interval that still represents the signal honestly, and pick the simplest noise model that communicates the behavior you need. Restart the app after changing tag definitions.

If you want the UI to show a new tag, update the projection in MainViewModel.Project. Do not bind the UI to the producer's internal dictionary. Read from AppState.LatestTagValues.

If you want a faster app-facing telemetry rate, change TelemetryIntervalMs in a profile and select that profile while the workflow is idle. Then watch:

  • telemetry.ingested
  • telemetry.coalesced
  • UI responsiveness
  • AppStateStore.Update cost if profiling is enabled

If you want more historical telemetry, do not make the existing channel unbounded. This pipeline is for latest operator state. Historical capture should be a separate consumer or persistence path with its own retention and backpressure policy.

If you replace the simulator with a real source, keep the contract:

  • publish TagSnapshot
  • expose a bounded ChannelReader<TagSnapshot>
  • make drop/coalesce policy explicit
  • complete the channel during shutdown
  • keep AppState updates snapshot-based

Debugging Checklist

When telemetry "looks wrong", ask these questions in order:

  1. Did the app start with valid Simulator:Tags?
  2. Is tags.active the expected count?
  3. Is the selected profile the one you think it is?
  4. Is telemetry.ingested close to 1000 / TelemetryIntervalMs per second?
  5. Are the per-tag samples.ingested{tag.name} counters present?
  6. Is samples.coalesced{tag.name} high only for fast tags?
  7. Is telemetry.coalesced near zero?
  8. Are temperature.celsius and pressure.bar still present if the UI readout is blank?
  9. Are you expecting sub-15 ms timing accuracy on Windows without a timer-resolution boost?
  10. Did you restart after changing tag definitions?

That order keeps you from chasing the wrong layer. Most issues are configuration, profile selection, timer limits, or misunderstanding the two coalesce counters.

The Takeaway

A good telemetry pipeline is not just "a loop that updates values."

In this app, the telemetry pipeline is a small production design:

  • typed configuration becomes domain records
  • independent emitters model real signal rates
  • a concurrent cell-store absorbs high-frequency updates
  • immutable snapshots cross the component boundary
  • a bounded channel encodes freshness-over-history policy
  • a background service writes canonical app state
  • the view model projects state safely onto the UI thread
  • metrics reveal whether the trade-off is behaving

Once you see that shape, the implementation becomes much easier to read. The code is not a collection of isolated C# tricks. It is one coherent answer to a real runtime problem: how do we make fast, uneven, continuous machine data useful to an operator without overwhelming the app?

Docs-first project memory for AI-assisted implementation.