Coverage for zenmqtt/instrumentation/opentelemetry.py: 97%

135 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-12 21:55 +0000

1""" 

2OpenTelemetry metrics and tracing for the client, requires 

3`pip install zenmqtt[otel]`. 

4 

5Messaging metrics and spans follow the OpenTelemetry semantic conventions, MQTT 

6specific metrics use the `zenmqtt.` prefix. They can be exported with any 

7OpenTelemetry exporter: OTLP, Prometheus, console, etc. 

8 

9Topics are not used as metric attributes: the number of topics isn't limited, 

10so it would make the number of time series unlimited too. For the same reason 

11span names don't contain the topic, it's the attribute 

12`messaging.destination.name` of the span. 

13""" 

14 

15import asyncio 

16from contextlib import contextmanager 

17from typing import Iterator, Mapping, Optional, Sequence, Tuple, cast 

18 

19from opentelemetry import context, propagate, trace 

20from opentelemetry.metrics import Meter, get_meter 

21from opentelemetry.propagators.textmap import Getter, TextMapPropagator 

22from opentelemetry.trace import Span, SpanKind, Status, StatusCode, TracerProvider 

23from opentelemetry.util.types import AttributeValue 

24 

25from zenmqtt.instrumentation.metrics import MetricsCollector 

26from zenmqtt.instrumentation.tracing import PublishTrace, Tracing 

27from zenmqtt.mqtt.packet import PacketType 

28from zenmqtt.mqtt.publish import ( 

29 MQTTMessage, 

30 PublishAcknowledgement, 

31 PublishProperties, 

32) 

33from zenmqtt.mqtt.reason_codes import FAILURE_REASON_CODE 

34 

35# recommended by the semantic conventions for durations in seconds 

36# packet sizes in bytes 

37_SIZE_BUCKETS = (16, 64, 256, 1024, 4096, 16384, 65536, 262144, 1048576) 

38 

39_DURATION_BUCKETS = ( 

40 0.005, 

41 0.01, 

42 0.025, 

43 0.05, 

44 0.075, 

45 0.1, 

46 0.25, 

47 0.5, 

48 0.75, 

49 1.0, 

50 2.5, 

51 5.0, 

52 7.5, 

53 10.0, 

54) 

55 

56 

57class OpenTelemetryMetrics(MetricsCollector): 

58 def __init__( 

59 self, 

60 meter: Optional[Meter] = None, 

61 attributes: Optional[Mapping[str, AttributeValue]] = None, 

62 ) -> None: 

63 """ 

64 :param meter: meter to create instruments, the global one by default 

65 :param attributes: added to every measurement, e.g. a client name 

66 """ 

67 meter = meter or get_meter("zenmqtt") 

68 

69 self._attributes: dict[str, AttributeValue] = { 

70 "messaging.system": "mqtt", 

71 **(attributes or {}), 

72 } 

73 

74 self._operation_duration = meter.create_histogram( 

75 "messaging.client.operation.duration", 

76 unit="s", 

77 description="Duration of publish, subscribe and unsubscribe operations", 

78 explicit_bucket_boundaries_advisory=_DURATION_BUCKETS, 

79 ) 

80 self._sent_messages = meter.create_counter( 

81 "messaging.client.sent.messages", 

82 unit="{message}", 

83 description="Number of published messages", 

84 ) 

85 self._consumed_messages = meter.create_counter( 

86 "messaging.client.consumed.messages", 

87 unit="{message}", 

88 description="Number of messages delivered to the application", 

89 ) 

90 

91 self._connect_duration = meter.create_histogram( 

92 "zenmqtt.connect.duration", 

93 unit="s", 

94 description="Time from sending CONNECT to receiving CONNACK", 

95 explicit_bucket_boundaries_advisory=_DURATION_BUCKETS, 

96 ) 

97 self._active_connections = meter.create_up_down_counter( 

98 "zenmqtt.connections.active", 

99 unit="{connection}", 

100 description="Number of established connections", 

101 ) 

102 self._closed_connections = meter.create_counter( 

103 "zenmqtt.connections.closed", 

104 unit="{connection}", 

105 description="Number of closed connections", 

106 ) 

107 # one histogram instead of counters of packets and bytes: every 

108 # measurement has a fixed cost in the SDK, and a histogram gives both, 

109 # the number of packets (count) and of bytes (sum) 

110 self._sent_packets = meter.create_histogram( 

111 "zenmqtt.packets.sent", 

112 unit="By", 

113 description="Sizes of sent packets: count of packets, sum of bytes", 

114 explicit_bucket_boundaries_advisory=_SIZE_BUCKETS, 

115 ) 

116 self._received_packets = meter.create_histogram( 

117 "zenmqtt.packets.received", 

118 unit="By", 

119 description="Sizes of received packets: count of packets, sum of bytes", 

120 explicit_bucket_boundaries_advisory=_SIZE_BUCKETS, 

121 ) 

122 

123 # attributes are built once, not for every measurement 

124 self._packet_attributes: dict[PacketType, dict[str, AttributeValue]] = { 

125 packet_type: {**self._attributes, "mqtt.packet.type": packet_type.name} 

126 for packet_type in PacketType 

127 } 

128 self._received_attributes: dict[int, dict[str, AttributeValue]] = { 

129 qos: { 

130 **self._attributes, 

131 "messaging.operation.name": "receive", 

132 "messaging.operation.type": "receive", 

133 "mqtt.qos": qos, 

134 } 

135 for qos in (0, 1, 2) 

136 } 

137 self._published_attributes: dict[ 

138 tuple[int, int], dict[str, AttributeValue] 

139 ] = {} 

140 self._buffered_messages = meter.create_gauge( 

141 "zenmqtt.messages.buffered", 

142 unit="{message}", 

143 description=( 

144 "Number of incoming messages waiting for the application, " 

145 "reported while it's above the warning threshold" 

146 ), 

147 ) 

148 self._send_quota_wait = meter.create_histogram( 

149 "zenmqtt.send_quota.wait.duration", 

150 unit="s", 

151 description='Time a publish waited because of the server "Receive Maximum"', 

152 explicit_bucket_boundaries_advisory=_DURATION_BUCKETS, 

153 ) 

154 self._resent_messages = meter.create_counter( 

155 "zenmqtt.messages.resent", 

156 unit="{message}", 

157 description="Number of pending messages re-sent after reconnect", 

158 ) 

159 self._duplicated_messages = meter.create_counter( 

160 "zenmqtt.messages.duplicated", 

161 unit="{message}", 

162 description="Number of duplicated QoS 2 messages, not delivered again", 

163 ) 

164 self._ping_duration = meter.create_histogram( 

165 "zenmqtt.ping.duration", 

166 unit="s", 

167 description="Time from sending PINGREQ to receiving PINGRESP", 

168 explicit_bucket_boundaries_advisory=_DURATION_BUCKETS, 

169 ) 

170 self._reconnect_attempts = meter.create_counter( 

171 "zenmqtt.reconnect.attempts", 

172 unit="{attempt}", 

173 description="Number of automatic reconnect attempts", 

174 ) 

175 self._reconnect_give_ups = meter.create_counter( 

176 "zenmqtt.reconnect.give_ups", 

177 unit="{event}", 

178 description="Number of times automatic reconnect stopped for good", 

179 ) 

180 self._ping_timeouts = meter.create_counter( 

181 "zenmqtt.ping.timeouts", 

182 unit="{timeout}", 

183 description="Number of connections closed by keep alive", 

184 ) 

185 

186 def on_connect(self, reason_code: int, duration: float) -> None: 

187 attributes = self._with_reason_code(reason_code) 

188 

189 self._connect_duration.record(duration, attributes) 

190 

191 if reason_code < FAILURE_REASON_CODE: 

192 self._active_connections.add(1, self._attributes) 

193 

194 def on_connection_closed(self, lost: bool) -> None: 

195 self._active_connections.add(-1, self._attributes) 

196 self._closed_connections.add( 

197 1, {**self._attributes, "zenmqtt.connection.lost": lost} 

198 ) 

199 

200 def on_packet_sent(self, packet_type: PacketType, size: int) -> None: 

201 self._sent_packets.record(size, self._packet_attributes[packet_type]) 

202 

203 def on_packet_received(self, packet_type: PacketType, size: int) -> None: 

204 self._received_packets.record(size, self._packet_attributes[packet_type]) 

205 

206 def on_publish_completed(self, qos: int, reason_code: int, duration: float) -> None: 

207 if (attributes := self._published_attributes.get((qos, reason_code))) is None: 207 ↛ 216line 207 didn't jump to line 216 because the condition on line 207 was always true

208 attributes = { 

209 **self._with_reason_code(reason_code), 

210 "messaging.operation.name": "publish", 

211 "messaging.operation.type": "send", 

212 "mqtt.qos": qos, 

213 } 

214 self._published_attributes[(qos, reason_code)] = attributes 

215 

216 self._operation_duration.record(duration, attributes) 

217 self._sent_messages.add(1, attributes) 

218 

219 def on_message_received(self, qos: int, duplicate: bool) -> None: 

220 attributes = self._received_attributes[qos] 

221 

222 if duplicate: 

223 self._duplicated_messages.add(1, attributes) 

224 else: 

225 self._consumed_messages.add(1, attributes) 

226 

227 def on_messages_buffered(self, count: int) -> None: 

228 self._buffered_messages.set(count, self._attributes) 

229 

230 def on_send_quota_wait(self, duration: float) -> None: 

231 self._send_quota_wait.record(duration, self._attributes) 

232 

233 def on_messages_resent(self, count: int) -> None: 

234 self._resent_messages.add(count, self._attributes) 

235 

236 def on_ping(self, duration: float) -> None: 

237 self._ping_duration.record(duration, self._attributes) 

238 

239 def on_reconnect_attempt(self, attempt: int, delay: float) -> None: 

240 self._reconnect_attempts.add(1, self._attributes) 

241 

242 def on_reconnect_gave_up(self) -> None: 

243 self._reconnect_give_ups.add(1, self._attributes) 

244 

245 def on_ping_timeout(self) -> None: 

246 self._ping_timeouts.add(1, self._attributes) 

247 

248 def on_subscribe_completed( 

249 self, reason_codes: Sequence[int], duration: float 

250 ) -> None: 

251 self._record_command("subscribe", reason_codes, duration) 

252 

253 def on_unsubscribe_completed( 

254 self, reason_codes: Sequence[int], duration: float 

255 ) -> None: 

256 self._record_command("unsubscribe", reason_codes, duration) 

257 

258 def _record_command( 

259 self, operation: str, reason_codes: Sequence[int], duration: float 

260 ) -> None: 

261 attributes: dict[str, AttributeValue] = { 

262 **self._attributes, 

263 "messaging.operation.name": operation, 

264 } 

265 

266 # one reason code per topic, the operation failed if any topic failed 

267 if failed := [code for code in reason_codes if code >= FAILURE_REASON_CODE]: 

268 attributes["error.type"] = _format_reason_code(failed[0]) 

269 

270 self._operation_duration.record(duration, attributes) 

271 

272 def _with_reason_code(self, reason_code: int) -> dict[str, AttributeValue]: 

273 attributes: dict[str, AttributeValue] = { 

274 **self._attributes, 

275 "mqtt.reason_code": reason_code, 

276 } 

277 

278 if reason_code >= FAILURE_REASON_CODE: 

279 attributes["error.type"] = _format_reason_code(reason_code) 

280 

281 return attributes 

282 

283 

284def _format_reason_code(reason_code: int) -> str: 

285 return f"0x{reason_code:02X}" 

286 

287 

288UserProperties = Sequence[Tuple[str, str]] 

289 

290 

291# mypy runs without site packages, so Getter is Any for it 

292class _UserPropertiesGetter(Getter[UserProperties]): # type: ignore[misc,unused-ignore] 

293 """Reads the trace context from MQTT 5 user properties, (key, value) pairs.""" 

294 

295 def get(self, carrier: UserProperties, key: str) -> Optional[list[str]]: 

296 return [value for name, value in carrier if name == key] or None 

297 

298 def keys(self, carrier: UserProperties) -> list[str]: 

299 return [name for name, _ in carrier] 

300 

301 

302_USER_PROPERTIES_GETTER = _UserPropertiesGetter() 

303 

304 

305class _PublishSpan(PublishTrace): 

306 def __init__(self, properties: PublishProperties, span: Span) -> None: 

307 super().__init__(properties) 

308 self._span = span 

309 

310 def end( 

311 self, 

312 result: Optional[PublishAcknowledgement], 

313 error: Optional[BaseException], 

314 ) -> None: 

315 span = self._span 

316 

317 # a cancelled QoS 1/2 call doesn't cancel the delivery, it's not an error 

318 if error is not None and not isinstance(error, asyncio.CancelledError): 

319 span.record_exception(error) 

320 span.set_attribute("error.type", type(error).__qualname__) 

321 span.set_status(Status(StatusCode.ERROR, str(error))) 

322 elif result is not None: 

323 span.set_attribute("mqtt.reason_code", result.reason_code) 

324 

325 if result.reason_code >= FAILURE_REASON_CODE: 325 ↛ 331line 325 didn't jump to line 331 because the condition on line 325 was always true

326 span.set_attribute( 

327 "error.type", _format_reason_code(result.reason_code) 

328 ) 

329 span.set_status(Status(StatusCode.ERROR)) 

330 

331 span.end() 

332 

333 

334class OpenTelemetryTracing(Tracing): 

335 """ 

336 Spans of the messaging semantic conventions: 

337 

338 - "publish" (PRODUCER) for every publish call: until the packet is written 

339 for QoS 0, until the final acknowledgement for QoS 1/2. Its context is 

340 injected into the user properties of PUBLISH. 

341 - "process" (CONSUMER) for MQTTClient.trace(): the context from the 

342 user properties of the message is its parent, so the processing 

343 continues the trace of the producer. 

344 """ 

345 

346 def __init__( 

347 self, 

348 tracer_provider: Optional[TracerProvider] = None, 

349 propagator: Optional[TextMapPropagator] = None, 

350 attributes: Optional[Mapping[str, AttributeValue]] = None, 

351 ) -> None: 

352 """ 

353 :param tracer_provider: the global one by default 

354 :param propagator: format of the trace context in user properties, the 

355 global one by default (W3C traceparent and baggage) 

356 :param attributes: added to every span, e.g. a client name 

357 """ 

358 self._tracer = trace.get_tracer("zenmqtt", tracer_provider=tracer_provider) 

359 self._propagator = propagator 

360 

361 self._attributes: dict[str, AttributeValue] = { 

362 "messaging.system": "mqtt", 

363 **(attributes or {}), 

364 } 

365 

366 def start_publish( 

367 self, 

368 client_id: str, 

369 topic: str, 

370 payload: bytes, 

371 qos: int, 

372 retain: bool, 

373 properties: PublishProperties, 

374 ) -> PublishTrace: 

375 span = self._tracer.start_span( 

376 "publish", 

377 kind=SpanKind.PRODUCER, 

378 attributes={ 

379 **self._attributes, 

380 "messaging.operation.name": "publish", 

381 "messaging.operation.type": "send", 

382 "messaging.destination.name": topic, 

383 "messaging.client.id": client_id, 

384 "messaging.message.body.size": len(payload), 

385 "mqtt.qos": qos, 

386 "mqtt.retain": retain, 

387 }, 

388 ) 

389 

390 carrier: dict[str, str] = {} 

391 self._get_propagator().inject(carrier, trace.set_span_in_context(span)) 

392 

393 if carrier: 393 ↛ 407line 393 didn't jump to line 407 because the condition on line 393 was always true

394 # the context replaces a context of the application, e.g. of a 

395 # message which is forwarded 

396 user_properties = [ 

397 (name, value) 

398 for name, value in properties.get("user_property", ()) 

399 if name not in carrier 

400 ] 

401 user_properties.extend(carrier.items()) 

402 

403 properties = cast( 

404 PublishProperties, {**properties, "user_property": user_properties} 

405 ) 

406 

407 return _PublishSpan(properties, span) 

408 

409 @contextmanager 

410 def trace(self, client_id: str, message: MQTTMessage) -> Iterator[None]: 

411 # without a context in the message, the current span is the parent 

412 parent = self._get_propagator().extract( 

413 message.properties.get("user_property", ()), 

414 context.get_current(), 

415 getter=_USER_PROPERTIES_GETTER, 

416 ) 

417 

418 with self._tracer.start_as_current_span( 

419 "process", 

420 context=parent, 

421 kind=SpanKind.CONSUMER, 

422 attributes={ 

423 **self._attributes, 

424 "messaging.operation.name": "process", 

425 "messaging.operation.type": "process", 

426 "messaging.destination.name": message.topic, 

427 "messaging.client.id": client_id, 

428 "messaging.message.body.size": len(message.payload), 

429 "mqtt.qos": message.qos, 

430 "mqtt.retain": message.retain, 

431 "mqtt.dup": message.dup, 

432 }, 

433 ): 

434 yield 

435 

436 def _get_propagator(self) -> TextMapPropagator: 

437 # the global propagator may be configured after the client is created 

438 return self._propagator or propagate.get_global_textmap()