O-RAN Telemetry Reference
This checked-in job spec is what expanso-cli job deploy sends to Expanso Cloud. It passes expanso-edge validate, which checks the config schema, every Bloblang mapping, and the component types. A site-specific exporter or adapter is still required before real DU data reaches it; the built-in simulator runs alongside the real inputs until you remove it as described in the run path.
# O-RAN DU telemetry on Single-Node OpenShift (SNO)
#
# This is an Expanso job spec. Deploy it through Expanso Cloud:
#
# expanso-cli job deploy oran-telco-pipeline.yaml
#
# The job targets the Expanso Edge agent that runs on the SNO node as a
# restricted-SCC Deployment (see examples/integrations/oran-telco-pipeline/openshift/).
# Every path, host, and credential below is satisfied by those manifests:
#
# /data PersistentVolumeClaim oran-telemetry-data
# /etc/expanso/kafka-ca/ca.crt Secret oran-kafka-cluster-ca-cert (AMQ Streams)
# KAFKA_USERNAME/KAFKA_PASSWORD Secret expanso-oran (KafkaUser, SCRAM-SHA-512)
# OTEL_ENDPOINT Service oran-collector:4318 (OpenTelemetry Collector)
#
# Validate offline before deploying:
#
# expanso-edge validate oran-telco-pipeline.yaml
name: oran-edge-telemetry
description: Normalize DU telemetry on an SNO node and fan out to OpenTelemetry, Parquet on a PVC, and AMQ Streams Kafka
type: pipeline
namespace: default
labels:
example: oran-telco-pipeline
platform: openshift-sno
# Node labels are set in the edge agent's config (edge-config.yaml in the ConfigMap).
selector:
match_labels:
platform: openshift-sno
role: oran-du-telemetry
config:
rate_limit_resources:
- label: du_api_polling
local:
count: 1
interval: 1s
input:
broker:
inputs:
# 1. Adapter push. The DU exporter pod POSTs decoded telemetry to the
# agent's in-cluster Service (expanso-edge:8080). No hostPath is needed.
- label: du_adapter_push
http_server:
address: 0.0.0.0:8080
path: /telemetry
allowed_verbs: [POST]
timeout: 30s
processors:
- mapping: |
root = this
root.collection_method = "adapter_push"
root.collector_timestamp = now()
root.du_id = this.du_id.or(env("DU_ID")).or("du-unknown")
root.cell_id = this.cell_id.or(env("CELL_ID")).or("cell-unknown")
root.gnb_id = this.gnb_id.or(env("GNB_ID")).or("gnb-unknown")
# 2. DU API polling over the cluster network.
- label: du_api_realtime
http_client:
url: '${DU_ENDPOINT:http://oran-du.oran-du.svc:8080}/api/v1/metrics/realtime'
verb: GET
rate_limit: du_api_polling
headers:
Authorization: 'Bearer ${DU_API_KEY}'
Accept: application/json
User-Agent: expanso-edge-oran-collector/2.0.0
timeout: 30s
retries: 3
retry_period: 1s
max_retry_backoff: 30s
processors:
- mapping: |
root = this
root.collection_method = "api_realtime"
root.collector_timestamp = now()
root.du_id = this.du_id.or(env("DU_ID")).or("du-unknown")
root.cell_id = this.cell_id.or(env("CELL_ID")).or("cell-unknown")
root.gnb_id = this.gnb_id.or(env("GNB_ID")).or("gnb-unknown")
# 3. Simulated DU telemetry so the job produces data before a DU is wired in.
- label: du_simulator
generate:
interval: '${SIMULATION_INTERVAL:10s}'
count: 0
mapping: |
root.timestamp = now()
root.collection_method = "simulation"
let du_number = random_int(min: 1, max: 8).string()
root.du_id = "du-00" + $du_number
root.cell_id = "cell-" + $du_number
root.gnb_id = env("GNB_ID").or("gnb-seattle-01")
# PTP timing: +/-100 ns baseline with +/-10 ns jitter
root.ptp4l_offset_ns = (random_int(max: 200) - 100) + (random_int(max: 20) - 10)
# PRB utilization with a business-hours bump
let hour = now().ts_format("15").number()
let business_hour_factor = if $hour >= 8 && $hour <= 18 { 1.5 } else { 0.7 }
root.prb_dl_pct = [(random_int(min: 20, max: 70) * $business_hour_factor).round(), 95].min()
root.prb_ul_pct = [(random_int(min: 10, max: 40) * $business_hour_factor).round(), 85].min()
root.cpu_pct = random_int(min: 25, max: 85)
root.memory_pct = random_int(min: 40, max: 80)
root.disk_pct = random_int(min: 15, max: 60)
root.rsrp_dbm = 0 - random_int(min: 70, max: 110)
root.sinr_db = random_int(max: 27) - 2
root.active_ue_count = random_int(min: 5, max: 150)
root.network_rx_mbps = random_int(min: 50, max: 800)
root.network_tx_mbps = random_int(min: 20, max: 400)
pipeline:
processors:
# Drop records that cannot be attributed to a cell.
- label: input_validation
mapping: |
root = if this.du_id.or("") != "" && this.cell_id.or("") != "" && this.timestamp != null {
this
} else {
deleted()
}
# Normalize timestamps. RFC 3339 strings and Unix seconds are both accepted.
- label: timestamp_processing
mapping: |
let ts = this.timestamp.or(now())
root = this
root.timestamp_ns = $ts.ts_unix_nano()
root.timestamp_iso = $ts.ts_format("2006-01-02T15:04:05.000000000Z07:00")
root.processed_at = now()
root.hour_of_day = $ts.ts_format("15").number()
root.day_of_week = $ts.ts_format("Monday")
root.is_business_hours = root.hour_of_day >= 8 && root.hour_of_day <= 18
# Site metadata from the agent's environment (ConfigMap oran-site).
- label: site_enrichment
mapping: |
root = this
root.region = env("REGION").or("us-west-2")
root.market = env("MARKET").or("seattle")
root.site_type = env("SITE_TYPE").or("urban-macro")
root.gnb_id = if this.gnb_id.or("gnb-unknown") == "gnb-unknown" {
"gnb-" + root.region + "-" + this.du_id
} else {
this.gnb_id
}
root.sector = match this.cell_id {
"cell-1" => "alpha"
"cell-2" => "beta"
"cell-3" => "gamma"
_ => "omni"
}
root.network_hierarchy = {
"region": root.region,
"market": root.market,
"site_id": root.gnb_id,
"du_id": this.du_id,
"cell_id": this.cell_id,
"sector": root.sector
}
# PTP review bands (ITU-T G.8271.1 offsets). Authored thresholds, not a timing diagnosis.
- label: ptp_analysis
mapping: |
let abs_offset = this.ptp4l_offset_ns.or(0).abs()
root = this
root.ptp_review = if this.ptp4l_offset_ns == null {
deleted()
} else if $abs_offset < 100 {
"within_band"
} else if $abs_offset < 1000 {
"degraded"
} else if $abs_offset < 10000 {
"critical"
} else {
"outside_band"
}
root.ptp_score = if this.ptp4l_offset_ns == null {
deleted()
} else if $abs_offset < 100 {
100
} else if $abs_offset < 1000 {
(100 - (($abs_offset - 100) / 9)).round()
} else if $abs_offset < 10000 {
[(90 - (($abs_offset - 1000) / 100)).round(), 0].max()
} else {
0
}
root.ptp_alert_level = match root.ptp_review {
"within_band" => "none"
"degraded" => "warning"
"critical" => "major"
"outside_band" => "critical"
_ => deleted()
}
# PRB utilization and capacity bands.
- label: capacity_analysis
mapping: |
root = this
root = if this.prb_dl_pct == null || this.prb_ul_pct == null {
this
} else {
this.merge({
"prb_total_utilization": (this.prb_dl_pct + this.prb_ul_pct) / 2,
"prb_efficiency": ((this.prb_dl_pct + this.prb_ul_pct) / 2).round(),
"congestion_level": match {
this.prb_dl_pct < 50 => "low"
this.prb_dl_pct < 80 => "medium"
this.prb_dl_pct < 95 => "high"
_ => "critical"
},
"capacity_headroom_pct": 100 - this.prb_dl_pct,
"spectral_efficiency_dl": match {
this.prb_dl_pct < 30 => 2.4
this.prb_dl_pct < 70 => 3.2
this.prb_dl_pct < 90 => 2.8
_ => 1.8
},
"time_to_congestion_min": if this.prb_dl_pct > 70 {
((95 - this.prb_dl_pct) / 0.5).round()
} else {
null
}
})
}
# RF quality bands (3GPP TS 36.133 RSRP ranges, authored SINR ranges).
- label: rf_analysis
mapping: |
let rsrp_score = match {
this.rsrp_dbm == null => 0
this.rsrp_dbm > -80 => 100
this.rsrp_dbm > -90 => 80
this.rsrp_dbm > -100 => 60
this.rsrp_dbm > -110 => 40
_ => 20
}
let sinr_score = match {
this.sinr_db == null => 0
this.sinr_db > 20 => 100
this.sinr_db > 13 => 80
this.sinr_db > 0 => 60
this.sinr_db > -6 => 40
_ => 20
}
root = this
root = if this.rsrp_dbm == null || this.sinr_db == null {
this
} else {
this.merge({
"rsrp_quality": match {
this.rsrp_dbm > -80 => "excellent"
this.rsrp_dbm > -90 => "good"
this.rsrp_dbm > -100 => "fair"
this.rsrp_dbm > -110 => "poor"
_ => "very_poor"
},
"sinr_quality": match {
this.sinr_db > 20 => "excellent"
this.sinr_db > 13 => "good"
this.sinr_db > 0 => "fair"
this.sinr_db > -6 => "poor"
_ => "very_poor"
},
"rf_health_score": (($rsrp_score + $sinr_score) / 2).round(),
"estimated_coverage_radius_m": match {
this.rsrp_dbm > -80 => 500
this.rsrp_dbm > -90 => 1000
this.rsrp_dbm > -100 => 2000
this.rsrp_dbm > -110 => 3000
_ => 5000
}
})
}
# Data quality flags and pipeline metadata.
- label: data_quality
mapping: |
let quality = {
"ptp_valid": this.ptp4l_offset_ns != null,
"prb_valid": this.prb_dl_pct != null && this.prb_ul_pct != null,
"rf_valid": this.rsrp_dbm != null && this.sinr_db != null,
"system_valid": this.cpu_pct != null,
"timestamp_valid": this.timestamp_ns != null
}
let valid_count = $quality.values().filter(v -> v == true).length()
root = this
root.data_quality = $quality
root.data_quality_score = ($valid_count / 5) * 100
root.pipeline_metadata = {
"pipeline_id": "oran-edge-telemetry",
"pipeline_version": "2.0.0",
"processing_node": env("NODE_ID").or("unknown"),
"processing_timestamp": now().ts_format("2006-01-02T15:04:05Z07:00"),
"data_quality_score": root.data_quality_score
}
# Agent-side metrics. The edge agent exports these over OTLP to the same
# collector; see the telemetry block in edge-config.yaml.
- label: pipeline_metrics
metric:
type: counter
name: oran_telemetry_messages_processed
labels:
pipeline_id: oran-edge-telemetry
du_id: '${! this.du_id }'
region: '${! this.region }'
collection_method: '${! this.collection_method }'
- metric:
type: timing
name: oran_telemetry_processing_latency_ns
value: '${! now().ts_unix_nano() - this.timestamp_ns }'
- metric:
type: gauge
name: oran_telemetry_data_quality_score
value: '${! this.data_quality_score }'
labels:
du_id: '${! this.du_id }'
output:
broker:
pattern: fan_out
outputs:
# 1. Observability: OTLP/HTTP JSON to the OpenTelemetry Collector on the
# node. OpenShift user-workload monitoring scrapes the collector's
# Prometheus exporter (ServiceMonitor in the manifests). Records that
# cannot be delivered land in a dead-letter file on the PVC.
- label: otel_collector
processors:
- mapping: |
root = {
"resourceMetrics": [{
"resource": {
"attributes": [
{"key": "du_id", "value": {"stringValue": this.du_id}},
{"key": "cell_id", "value": {"stringValue": this.cell_id}},
{"key": "gnb_id", "value": {"stringValue": this.gnb_id}},
{"key": "region", "value": {"stringValue": this.region}}
]
},
"scopeMetrics": [{
"scope": {"name": "oran.telemetry", "version": "2.0.0"},
"metrics": [
{
"name": "oran_ptp_offset_nanoseconds",
"description": "PTP timing offset from master clock",
"unit": "ns",
"gauge": {"dataPoints": [{
"timeUnixNano": this.timestamp_ns.string(),
"asInt": this.ptp4l_offset_ns
}]}
},
{
"name": "oran_ptp_review_score",
"description": "PTP review score (0-100)",
"unit": "1",
"gauge": {"dataPoints": [{
"timeUnixNano": this.timestamp_ns.string(),
"asDouble": this.ptp_score
}]}
},
{
"name": "oran_prb_utilization_percent",
"description": "PRB utilization percentage",
"unit": "%",
"gauge": {"dataPoints": [
{
"timeUnixNano": this.timestamp_ns.string(),
"asDouble": this.prb_dl_pct,
"attributes": [{"key": "direction", "value": {"stringValue": "downlink"}}]
},
{
"timeUnixNano": this.timestamp_ns.string(),
"asDouble": this.prb_ul_pct,
"attributes": [{"key": "direction", "value": {"stringValue": "uplink"}}]
}
]}
},
{
"name": "oran_cpu_utilization_percent",
"description": "DU CPU utilization",
"unit": "%",
"gauge": {"dataPoints": [{
"timeUnixNano": this.timestamp_ns.string(),
"asDouble": this.cpu_pct
}]}
},
{
"name": "oran_rf_health_score",
"description": "RF quality health score",
"unit": "1",
"gauge": {"dataPoints": [{
"timeUnixNano": this.timestamp_ns.string(),
"asDouble": this.rf_health_score
}]}
}
]
}]
}]
}
fallback:
- http_client:
url: '${OTEL_ENDPOINT:http://oran-collector.oran-telemetry.svc:4318}/v1/metrics'
verb: POST
headers:
Content-Type: application/json
timeout: 30s
retries: 3
retry_period: 1s
max_retry_backoff: 30s
- file:
path: '/data/dead-letter/otel-${! now().ts_format("2006-01-02") }.jsonl'
codec: lines
processors:
- mapping: |
root = {
"error_timestamp": now(),
"error_destination": "otel_collector",
"error_message": meta("fallback_error"),
"original_payload": this
}
# 2. Long-term storage: Parquet files on the PVC mounted at /data.
# Each batch is encoded in memory and written as one file.
- label: parquet_pvc
processors:
- mapping: |
root = {
"timestamp_ns": this.timestamp_ns,
"date": this.timestamp_iso.slice(0, 10),
"hour": this.hour_of_day,
"du_id": this.du_id,
"cell_id": this.cell_id,
"gnb_id": this.gnb_id,
"region": this.region,
"sector": this.sector,
"ptp_offset_ns": this.ptp4l_offset_ns,
"ptp_review": this.ptp_review,
"ptp_score": this.ptp_score,
"prb_dl_pct": this.prb_dl_pct,
"prb_ul_pct": this.prb_ul_pct,
"prb_efficiency": this.prb_efficiency,
"cpu_pct": this.cpu_pct,
"memory_pct": this.memory_pct,
"rsrp_dbm": this.rsrp_dbm,
"sinr_db": this.sinr_db,
"rf_health_score": this.rf_health_score,
"data_quality_score": this.data_quality_score
}
broker:
batching:
count: 10000
period: 5m
byte_size: 10000000
processors:
- parquet_encode:
default_compression: zstd
schema:
- { name: timestamp_ns, type: INT64 }
- { name: date, type: UTF8 }
- { name: hour, type: INT32 }
- { name: du_id, type: UTF8 }
- { name: cell_id, type: UTF8 }
- { name: gnb_id, type: UTF8 }
- { name: region, type: UTF8 }
- { name: sector, type: UTF8 }
- { name: ptp_offset_ns, type: INT64, optional: true }
- { name: ptp_review, type: UTF8, optional: true }
- { name: ptp_score, type: INT32, optional: true }
- { name: prb_dl_pct, type: DOUBLE, optional: true }
- { name: prb_ul_pct, type: DOUBLE, optional: true }
- { name: prb_efficiency, type: DOUBLE, optional: true }
- { name: cpu_pct, type: DOUBLE, optional: true }
- { name: memory_pct, type: DOUBLE, optional: true }
- { name: rsrp_dbm, type: DOUBLE, optional: true }
- { name: sinr_db, type: DOUBLE, optional: true }
- { name: rf_health_score, type: DOUBLE, optional: true }
- { name: data_quality_score, type: DOUBLE }
outputs:
- file:
path: '/data/oran-telemetry/date=${! now().ts_format("2006-01-02") }/node=${! env("NODE_ID").or("unknown") }/part-${! uuid_v4() }.parquet'
codec: all-bytes
# 3. Analytics: AMQ Streams (Strimzi) Kafka on the cluster, TLS listener
# on 9093 with SCRAM-SHA-512. The cluster CA is mounted from the
# operator-managed Secret. Cloudera consumes from this topic.
- label: amq_streams_kafka
processors:
- mapping: |
root = {
"schema": {"type": "oran_telemetry", "version": "2.0.0"},
"metadata": {
"event_time": this.timestamp_iso,
"ingestion_time": now().ts_format("2006-01-02T15:04:05Z07:00"),
"pipeline_version": "2.0.0",
"data_quality_score": this.data_quality_score
},
"network": this.network_hierarchy,
"telemetry": {
"timing": {
"ptp_offset_ns": this.ptp4l_offset_ns,
"review": this.ptp_review,
"score": this.ptp_score
},
"resources": {
"prb_dl_percent": this.prb_dl_pct,
"prb_ul_percent": this.prb_ul_pct,
"efficiency": this.prb_efficiency,
"congestion": this.congestion_level
},
"system": {
"cpu_percent": this.cpu_pct,
"memory_percent": this.memory_pct
},
"radio": {
"rsrp_dbm": this.rsrp_dbm,
"sinr_db": this.sinr_db,
"health_score": this.rf_health_score
}
}
}
fallback:
- kafka:
addresses:
[
'${KAFKA_BOOTSTRAP_SERVERS:oran-kafka-kafka-bootstrap.oran-telemetry.svc:9093}',
]
topic: oran-telemetry-structured
key: '${! this.network.site_id }-${! this.network.cell_id }'
client_id: expanso-oran-pipeline
tls:
enabled: true
root_cas_file: /etc/expanso/kafka-ca/ca.crt
sasl:
mechanism: SCRAM-SHA-512
user: '${KAFKA_USERNAME}'
password: '${KAFKA_PASSWORD}'
compression: lz4
idempotent_write: false
ack_replicas: true
max_msg_bytes: 10485760
batching:
count: 500
period: 5s
- file:
path: '/data/dead-letter/kafka-${! now().ts_format("2006-01-02") }.jsonl'
codec: lines
processors:
- mapping: |
root = {
"error_timestamp": now(),
"error_destination": "amq_streams_kafka",
"error_message": meta("fallback_error"),
"original_payload": this
}
Deployment manifests
The agent that runs this job is a restricted-SCC Deployment on the SNO node. The full set of manifests, including the AMQ Streams cluster, the OpenTelemetry Collector, the ServiceMonitor, and both PersistentVolumeClaims, is in examples/integrations/oran-telco-pipeline/openshift and applied with oc apply -k. The run path walks through the order and the Secrets created by hand.
# Single-Node OpenShift deployment for the O-RAN telemetry example.
#
# oc apply -k examples/integrations/oran-telco-pipeline/openshift
#
# Operators that must already be installed on the cluster:
# - AMQ Streams (Strimzi) for the Kafka, KafkaNodePool, KafkaTopic, KafkaUser kinds
# - Red Hat build of OpenTelemetry for the OpenTelemetryCollector kind
#
# Secrets are created with `oc create secret` (see the setup page), never committed.
# cluster/user-workload-monitoring.yaml is cluster-scoped and applied separately.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: oran-telemetry
resources:
- 00-namespace.yaml
- 10-serviceaccount.yaml
- 20-pvc-data.yaml
- 21-pvc-edge-state.yaml
- 30-site-configmap.yaml
- 31-edge-config.yaml
- 40-kafka-nodepool.yaml
- 41-kafka.yaml
- 42-kafka-topic-structured.yaml
- 43-kafka-topic-alerts.yaml
- 44-kafka-user.yaml
- 45-kafka-consumer.yaml
- 50-otel-collector.yaml
- 51-servicemonitor.yaml
- 60-expanso-edge-deployment.yaml
- 61-expanso-edge-service.yaml
- 62-telemetry-networkpolicy.yaml
# Expanso Edge agent on the SNO node, running under the restricted-v2 SCC.
#
# OpenShift assigns the UID and fsGroup from the namespace range, so there is
# no runAsUser here; both PersistentVolumeClaims are group-writable to that
# UID. There is no hostPath and no hostNetwork. DU telemetry reaches the agent
# over the expanso-edge Service, not through a mounted host directory.
#
# The pipeline itself is not in this manifest. It is deployed through Expanso
# Cloud (expanso-cli job deploy) and scheduled onto this agent by its labels.
apiVersion: apps/v1
kind: Deployment
metadata:
name: expanso-edge
namespace: oran-telemetry
labels:
app.kubernetes.io/name: expanso-edge
app.kubernetes.io/part-of: oran-telco-pipeline
spec:
replicas: 1
# ReadWriteOnce claims: never run two agents against the same state.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: expanso-edge
template:
metadata:
labels:
app.kubernetes.io/name: expanso-edge
app.kubernetes.io/part-of: oran-telco-pipeline
spec:
serviceAccountName: expanso-edge
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: expanso-edge
image: ghcr.io/expanso-io/expanso-edge:latest
args:
- run
- --no-watch
- --config
- /etc/expanso/edge-config.yaml
env:
- name: EXPANSO_EDGE_BOOTSTRAP_TOKEN
valueFrom:
secretKeyRef:
name: expanso-bootstrap
key: token
- name: NODE_ID
valueFrom:
fieldRef:
fieldPath: spec.nodeName
- name: KAFKA_PASSWORD
valueFrom:
secretKeyRef:
name: expanso-oran # created by the KafkaUser
key: password
- name: DU_API_KEY
valueFrom:
secretKeyRef:
name: oran-du-api
key: token
envFrom:
- configMapRef:
name: oran-site
ports:
- name: telemetry
containerPort: 8080
protocol: TCP
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
cpu: '1'
memory: 1Gi
volumeMounts:
- name: edge-config
mountPath: /etc/expanso/edge-config.yaml
subPath: edge-config.yaml
readOnly: true
- name: edge-state
mountPath: /var/lib/expanso
- name: data
mountPath: /data
- name: kafka-ca
mountPath: /etc/expanso/kafka-ca
readOnly: true
volumes:
- name: edge-config
configMap:
name: expanso-edge-config
- name: edge-state
persistentVolumeClaim:
claimName: expanso-edge-state
- name: data
persistentVolumeClaim:
claimName: oran-telemetry-data
- name: kafka-ca
secret:
secretName: oran-kafka-cluster-ca-cert # created by the Kafka CR
items:
- key: ca.crt
path: ca.crt
Adaptation boundary
Review the exporter contract, units, time semantics, thresholds, Parquet schema, Kafka envelope, retention, storage sizing, and credentials before execution. The manifests assume the cluster's default StorageClass and the operator-managed Kafka CA; change both if your SNO node differs.