Skip to main content

Telemetry Pipeline

The telemetry pipeline is a background service that batches messages from the adapter and publishes them to NATS JetStream. It implements both ITelemetryClient and IPropertiesClient — a single channel feeds two branches (telemetry vs reported properties). The canonical contract lives in the telemetry-pipeline spec.

Flow

Adapter.Telemetry.Send(deviceId, TelemetryMessage)
Adapter.Properties.Report(deviceId, JsonObject)


┌─────────────────────┐
│ Bounded Channel │ capacity: 50,000 (default)
│ DropWrite on full │ single reader, multi-writer
└────────┬────────────┘


┌─────────────────────┐
│ Batch Accumulator │ 256 messages OR 50 ms window
└────────┬────────────┘

┌────┴────────┐
│ │
▼ ▼
┌─────────┐ ┌──────────────────┐
│Telemetry│ │Reported Properties│
│ (batch) │ │ (semaphore-bound) │
└────┬───┘ └────────┬─────────┘
│ │
▼ ▼
Scatter- DB twin update
gather (jsonb_set + ver++)
JetStream +
publish JetStream publish

Both branches publish to the same JetStream stream (TELEMETRY); only the messageType header differs.

Hot-path bytes (no JSON round-trip)

TelemetryMessage.Payload is ReadOnlyMemory<byte>. The pipeline writes those bytes directly as the NATS message body — there is no parse/re-serialise on the telemetry branch. Per-message overhead is the channel write and one NATS publish.

If you need per-message routing decisions, do them in the adapter before Telemetry.Send and surface the result as a header or as a separate publish — do not reach into the bytes from inside the pipeline.

Telemetry branch

Telemetry is published to JetStream using a scatter-gather pattern:

  1. Scatter — fire all publishes in the batch without awaiting acks.
  2. Gather — collect all ack responses (pipelined, ≈1 RTT total).

Subject: telemetry.{providerId}::{deviceId}

Headers added per message:

HeaderValue
messageTypeProviderClientTelemetry
providerIdthe provider's id
providerClientIdthe device id
enqueuedTimeISO 8601 UTC timestamp

A fresh NatsHeaders instance is allocated per publish call because the NATS .NET client mutates it during publish — do not cache or reuse a NatsHeaders across messages.

Reported-properties branch

Each reported message requires a database update before publishing:

  1. Update device_twin_entity atomically — jsonb_set merges the reported document, increments ReportedVersion, sets LastActivity.
  2. If the twin row doesn't exist yet, it is created in the same call.
  3. Publish to JetStream with messageType: ProviderClientPropertiesChanged.

Concurrency is bounded by MaxReportedConcurrency (default 16) so a fleet-wide reported burst can't exhaust the PostgreSQL connection pool. The DB write and the NATS publish are paired — if the DB write fails, the publish is skipped to avoid storing inconsistent state.

The twin endpoints never accept reported writes; reported state flows exclusively through this branch.

Backpressure

The channel uses BoundedChannelFullMode.DropWrite. When the channel is full:

  • The new message is dropped (not queued, not blocked).
  • pipeline.messages.dropped increments.
  • A warning is logged, rate-limited so a sustained outage doesn't flood logs.

This guarantees Telemetry.Send and Properties.Report never block the adapter, even under extreme load. The trade-off is silent loss under sustained over-capacity; monitor the dropped counter and tune ChannelCapacity accordingly.

Configuration

{
"TelemetryPipeline": {
"ChannelCapacity": 50000,
"BatchSize": 256,
"FlushIntervalMs": 50,
"MaxReportedConcurrency": 16
}
}
OptionDefaultDescription
ChannelCapacity50,000Bounded channel size. Messages dropped when full. Sized to absorb ≈5 s of replication lag at 10 k msg/s.
BatchSize256Maximum messages per batch before flush.
FlushIntervalMs50Maximum wait time before flushing a partial batch.
MaxReportedConcurrency16Parallel reported-property DB updates. Bounds connection-pool use under burst.

Metrics

MetricTypeTags
pipeline.messages.receivedCountertype=telemetry|reported
pipeline.messages.publishedCounter
pipeline.messages.droppedCounter
pipeline.errorsCounterstage=parse|db|nats-ack|nats-reported
pipeline.batch.duration_msHistogram
pipeline.batch.sizeHistogram
pipeline.nats.publish_msHistogramscatter-gather duration
pipeline.db.update_msHistogramreported DB update + NATS publish
pipeline.reported.semaphore_wait_msHistogramwait for reported concurrency slot
pipeline.channel.depthGaugecurrent channel reader count

JetStream stream provisioning

The pipeline creates the TELEMETRY JetStream stream at startup if it doesn't exist (subjects: telemetry.>). Replication is decided by the NATS monitoring port (Nats:MonitoringPort, default 8222):

  • Cluster size ≥ Nats:ClusterReplicaThreshold (default 3) → NumReplicas = 3.
  • Otherwise → NumReplicas = 1.

The BEACONTOWER_EVENTS stream used by the CloudEvents publisher is created the same way.