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:Profilescontrols app-level simulator behavior, including how often a telemetry snapshot is published intoAppState.Simulator:Tagscontrols 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
MultiTagwhile 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.celsiuspressure.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:
{
"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:
{
"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 lookupsUnit: display/engineering metadataIntervalMs: how often that tag's own emitter tries to produce a sampleNoise: how the simulator calculates values over time
The validator enforces the guardrails:
- names cannot be empty
- names must be unique
IntervalMsmust be between 2 and 1000Noise.Kindmust beSine,Drift,RandomWalk, orStep- 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:
SimulatorTagsOptionsSimulatorTagOptionsNoiseOptions
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
NoiseModelvariant, such asSineNoise
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
Randominstance - its own
double statefor stateful noise, such as random walk - its own fixed delay interval from
TagDefinition.IntervalMs
The loop is simple:
- Wait for the tag interval with
Task.Delay(interval, ct). - Get the current UTC time.
- Evaluate the noise model.
- Optionally skip the publish if
TelemetryDropoutChancesays to simulate dropout. - Create a
TagSample. - Write that sample into
_latestValues["temperature.celsius"]. - 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:
Channel<TagSnapshot> capacity = 1
FullMode = DropOldestThat 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:
await foreach (var snapshot in _source.Reader.ReadAllAsync(stoppingToken))For every snapshot, it calls:
_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:
Temp: 25.3 deg C Pressure: 1.013 barThat is the full path:
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 TextBlockThe 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:
SimulatedTagSource
produces snapshots on its own schedule
TagStreamPipelineService
consumes snapshots when the host has started the background service
writes the latest snapshot into AppStateWithout 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:
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 snapshotThat 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
ITagStreamcannot 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:
TagDefinitionTagSampleTagSnapshotSimulatorProfileNoiseModelvariants
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:
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:
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:
ref double stateThe 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:
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:
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:
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 completedSo 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:
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:
0means cleanup has not run yet1means 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:
old value = 0
_disposed becomes 1
0 != 0 is false
cleanup continuesOn the second Dispose call:
old value = 1
_disposed stays 1
1 != 0 is true
return immediatelyThe atomic part matters. A normal check like this is not thread-safe:
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:
_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 publishedThis became important because the current DI registration aliases the same singleton under two service types:
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:
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:
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:
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:
_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:
_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.ingestedtelemetry.coalesced- UI responsiveness
AppStateStore.Updatecost 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
AppStateupdates snapshot-based
Debugging Checklist
When telemetry "looks wrong", ask these questions in order:
- Did the app start with valid
Simulator:Tags? - Is
tags.activethe expected count? - Is the selected profile the one you think it is?
- Is
telemetry.ingestedclose to1000 / TelemetryIntervalMsper second? - Are the per-tag
samples.ingested{tag.name}counters present? - Is
samples.coalesced{tag.name}high only for fast tags? - Is
telemetry.coalescednear zero? - Are
temperature.celsiusandpressure.barstill present if the UI readout is blank? - Are you expecting sub-15 ms timing accuracy on Windows without a timer-resolution boost?
- 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?