From a23beb03b2637d1ecbf174b75f5c0a384a630c69 Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Sun, 26 Apr 2026 11:56:53 +0100 Subject: [PATCH 01/10] feat: add injectable Metrics interface for instrumentation Adds a Prometheus-style Metrics interface (src/utils/metrics.ts) with one method per observable event, following the existing Logger pattern. Instrumentation covers: - Adapter sends (ZCL unicast/group/broadcast, ZDO): success/failure status and duration, via a Proxy-based wrapper (src/adapter/metricsAdapter.ts) that requires no changes to any concrete adapter implementation. - RequestQueue: queue depth gauge and time-in-queue histogram per device endpoint, tracked with a non-enumerable WeakMap to avoid serialisation side-effects. - Retries: per-adapter counters with reason labels in z-stack, ember, zigate, zboss, and zoh adapters (deconz has no retry logic). Consumers inject an implementation via setMetrics(); the default is a no-op so there is no overhead when metrics are not configured. Signed-off-by: Tom Wilkie --- src/adapter/ember/adapter/emberAdapter.ts | 3 + src/adapter/metricsAdapter.ts | 64 ++++++++++++++++++++ src/adapter/z-stack/adapter/zStackAdapter.ts | 4 ++ src/adapter/zboss/adapter/zbossAdapter.ts | 2 + src/adapter/zigate/adapter/zigateAdapter.ts | 3 + src/adapter/zoh/adapter/zohAdapter.ts | 4 +- src/controller/controller.ts | 5 +- src/controller/helpers/requestQueue.ts | 17 ++++++ src/index.ts | 2 + src/utils/metrics.ts | 25 ++++++++ 10 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 src/adapter/metricsAdapter.ts create mode 100644 src/utils/metrics.ts diff --git a/src/adapter/ember/adapter/emberAdapter.ts b/src/adapter/ember/adapter/emberAdapter.ts index 3b59ef9512..4e8f80d580 100644 --- a/src/adapter/ember/adapter/emberAdapter.ts +++ b/src/adapter/ember/adapter/emberAdapter.ts @@ -6,6 +6,7 @@ import equals from "fast-deep-equal/es6"; import type {Backup} from "../../../models"; import {BackupUtils, Queue, wait} from "../../../utils"; import {logger} from "../../../utils/logger"; +import {metrics} from "../../../utils/metrics"; import * as ZSpec from "../../../zspec"; import type {Eui64, ExtendedPanId, NodeId, PanId} from "../../../zspec/tstypes"; import * as Zcl from "../../../zspec/zcl"; @@ -2007,8 +2008,10 @@ export class EmberAdapter extends Adapter { } if (status === SLStatus.ZIGBEE_MAX_MESSAGE_LIMIT_REACHED || status === SLStatus.BUSY) { + metrics.adapterRetry("ember", ieeeAddr, SLStatus[status] ?? String(status)); await wait(QUEUE_BUSY_DEFER_MSEC); } else if (status === SLStatus.NETWORK_DOWN) { + metrics.adapterRetry("ember", ieeeAddr, "NETWORK_DOWN"); await wait(QUEUE_NETWORK_DOWN_DEFER_MSEC); } else { throw new Error( diff --git a/src/adapter/metricsAdapter.ts b/src/adapter/metricsAdapter.ts new file mode 100644 index 0000000000..233114376f --- /dev/null +++ b/src/adapter/metricsAdapter.ts @@ -0,0 +1,64 @@ +import type {Adapter} from "./adapter.js"; +import {metrics} from "../utils/metrics.js"; + +type SendStatus = "success" | "failure"; +type AnyAsyncFn = (...args: unknown[]) => Promise; + +function instrumentSend( + fn: AnyAsyncFn, + record: (args: unknown[], status: SendStatus, durationSeconds: number) => void, +): AnyAsyncFn { + return async (...args) => { + const start = Date.now(); + try { + const result = await fn(...args); + record(args, "success", (Date.now() - start) / 1000); + return result; + } catch (e) { + record(args, "failure", (Date.now() - start) / 1000); + throw e; + } + }; +} + +// biome-ignore lint/suspicious/noExplicitAny: cast needed to bridge typed adapter methods to generic AnyAsyncFn +function asFn(fn: (...args: any[]) => Promise): AnyAsyncFn { + return fn as AnyAsyncFn; +} + +export function wrapWithMetrics(adapter: Adapter): Adapter { + const dispatch = new Map([ + [ + "sendZclFrameToEndpoint", + instrumentSend(asFn(adapter.sendZclFrameToEndpoint.bind(adapter)), ([ieeeAddr], status, dur) => + metrics.adapterSendZclUnicast(ieeeAddr as string, status, dur), + ), + ], + [ + "sendZdo", + instrumentSend(asFn(adapter.sendZdo.bind(adapter)), ([ieeeAddr, , clusterId], status, dur) => + metrics.adapterSendZdo(ieeeAddr as string, clusterId as number, status, dur), + ), + ], + [ + "sendZclFrameToGroup", + instrumentSend(asFn(adapter.sendZclFrameToGroup.bind(adapter)), ([groupId], status, dur) => + metrics.adapterSendZclGroup(groupId as number, status, dur), + ), + ], + [ + "sendZclFrameToAll", + instrumentSend(asFn(adapter.sendZclFrameToAll.bind(adapter)), (_args, status, dur) => metrics.adapterSendZclBroadcast(status, dur)), + ], + ]); + + return new Proxy(adapter, { + get(target, prop) { + if (typeof prop === "string" && dispatch.has(prop)) { + return dispatch.get(prop); + } + const value = Reflect.get(target, prop, target); + return typeof value === "function" ? (value as Function).bind(target) : value; + }, + }); +} diff --git a/src/adapter/z-stack/adapter/zStackAdapter.ts b/src/adapter/z-stack/adapter/zStackAdapter.ts index e5f7256e6e..921feb425c 100644 --- a/src/adapter/z-stack/adapter/zStackAdapter.ts +++ b/src/adapter/z-stack/adapter/zStackAdapter.ts @@ -3,6 +3,7 @@ import debounce from "debounce"; import type * as Models from "../../../models"; import {Queue, Waitress, wait} from "../../../utils"; import {logger} from "../../../utils/logger"; +import {metrics} from "../../../utils/metrics"; import * as ZSpec from "../../../zspec"; import type {BroadcastAddress} from "../../../zspec/enums"; import type {Eui64} from "../../../zspec/tstypes"; @@ -584,6 +585,7 @@ export class ZStackAdapter extends Adapter { * MAC_NO_RESOURCES: Operation could not be completed because no memory resources are available, * wait some time and retry. */ + metrics.adapterRetry("zstack", ieeeAddr, ZnpCommandStatus[dataConfirmResult] ?? String(dataConfirmResult)); await wait(2000); return await this.sendZclFrameToEndpointInternal( ieeeAddr, @@ -665,6 +667,7 @@ export class ZStackAdapter extends Adapter { await wait(2000); } + metrics.adapterRetry("zstack", ieeeAddr, ZnpCommandStatus[dataConfirmResult] ?? String(dataConfirmResult)); return await this.sendZclFrameToEndpointInternal( ieeeAddr, networkAddress, @@ -716,6 +719,7 @@ export class ZStackAdapter extends Adapter { }); } // No response could be of invalid route, e.g. when message is send to wrong parent of end device. + metrics.adapterRetry("zstack", ieeeAddr, "no_response"); await this.discoverRoute(networkAddress); return await this.sendZclFrameToEndpointInternal( ieeeAddr, diff --git a/src/adapter/zboss/adapter/zbossAdapter.ts b/src/adapter/zboss/adapter/zbossAdapter.ts index e170709d47..a7caa83edd 100644 --- a/src/adapter/zboss/adapter/zbossAdapter.ts +++ b/src/adapter/zboss/adapter/zbossAdapter.ts @@ -4,6 +4,7 @@ import assert from "node:assert"; import type {Backup} from "../../../models"; import {Queue, Waitress} from "../../../utils"; import {logger} from "../../../utils/logger"; +import {metrics} from "../../../utils/metrics"; import * as ZSpec from "../../../zspec"; import * as Zcl from "../../../zspec/zcl"; import * as Zdo from "../../../zspec/zdo"; @@ -387,6 +388,7 @@ export class ZBOSSAdapter extends Adapter { } catch (error) { logger.debug(`Response timeout (${ieeeAddr}:${networkAddress},${responseAttempt})`, NS); if (responseAttempt < 1 && !disableRecovery) { + metrics.adapterRetry("zboss", ieeeAddr, "no_response"); return await this.sendZclFrameToEndpointInternal( ieeeAddr, networkAddress, diff --git a/src/adapter/zigate/adapter/zigateAdapter.ts b/src/adapter/zigate/adapter/zigateAdapter.ts index 26b62bed6c..7fa207a60d 100644 --- a/src/adapter/zigate/adapter/zigateAdapter.ts +++ b/src/adapter/zigate/adapter/zigateAdapter.ts @@ -3,6 +3,7 @@ import type * as Models from "../../../models"; import {Queue, Waitress, wait} from "../../../utils"; import {logger} from "../../../utils/logger"; +import {metrics} from "../../../utils/metrics"; import * as ZSpec from "../../../zspec"; import type {BroadcastAddress} from "../../../zspec/enums"; import * as Zcl from "../../../zspec/zcl"; @@ -368,6 +369,7 @@ export class ZiGateAdapter extends Adapter { } catch { if (responseAttempt < 1 && !disableRecovery) { // @todo discover route + metrics.adapterRetry("zigate", ieeeAddr, "send_failure"); return await this.sendZclFrameToEndpointInternal( ieeeAddr, networkAddress, @@ -395,6 +397,7 @@ export class ZiGateAdapter extends Adapter { } catch (error) { logger.error(`Response error ${(error as Error).message} (${ieeeAddr}:${networkAddress},${responseAttempt})`, NS); if (responseAttempt < 1 && !disableRecovery) { + metrics.adapterRetry("zigate", ieeeAddr, "no_response"); return await this.sendZclFrameToEndpointInternal( ieeeAddr, networkAddress, diff --git a/src/adapter/zoh/adapter/zohAdapter.ts b/src/adapter/zoh/adapter/zohAdapter.ts index ea5491f0bb..c9c8fb9645 100644 --- a/src/adapter/zoh/adapter/zohAdapter.ts +++ b/src/adapter/zoh/adapter/zohAdapter.ts @@ -8,6 +8,7 @@ import type {ZigbeeAPSHeader, ZigbeeAPSPayload} from "zigbee-on-host/dist/zigbee import type {ZigbeeNWKGPHeader} from "zigbee-on-host/dist/zigbee/zigbee-nwkgp"; import type {Backup} from "../../../models/backup"; import {logger} from "../../../utils/logger"; +import {metrics} from "../../../utils/metrics"; import {Queue} from "../../../utils/queue"; import {wait} from "../../../utils/wait"; import {Waitress} from "../../../utils/waitress"; @@ -645,7 +646,8 @@ export class ZoHAdapter extends Adapter { } catch (error) { if (disableRecovery || i === 1) { throw error; - } // else retry + } + metrics.adapterRetry("zoh", ieeeAddr, "send_failure"); } /* v8 ignore start */ } // coverage detection failure diff --git a/src/controller/controller.ts b/src/controller/controller.ts index a9cb232790..2b06965ee3 100644 --- a/src/controller/controller.ts +++ b/src/controller/controller.ts @@ -2,6 +2,7 @@ import assert from "node:assert"; import events from "node:events"; import fs from "node:fs"; import {Adapter, type Events as AdapterEvents, type TsType as AdapterTsType} from "../adapter"; +import {wrapWithMetrics} from "../adapter/metricsAdapter"; import type {ZclPayload} from "../adapter/events"; import {BackupUtils, wait} from "../utils"; import {logger} from "../utils/logger"; @@ -137,7 +138,9 @@ export class Controller extends events.EventEmitter { Entity.injectDatabase(this.database); // Adapter (create and inject) - this.adapter = await Adapter.create(this.options.network, this.options.serialPort, this.options.backupPath, this.options.adapter); + this.adapter = wrapWithMetrics( + await Adapter.create(this.options.network, this.options.serialPort, this.options.backupPath, this.options.adapter), + ); abortSignal?.throwIfAborted(); diff --git a/src/controller/helpers/requestQueue.ts b/src/controller/helpers/requestQueue.ts index f4ee731518..7050d3a406 100755 --- a/src/controller/helpers/requestQueue.ts +++ b/src/controller/helpers/requestQueue.ts @@ -1,5 +1,6 @@ import equal from "fast-deep-equal/es6"; import {logger} from "../../utils/logger"; +import {metrics} from "../../utils/metrics"; import type * as Zcl from "../../zspec/zcl"; import type {Endpoint} from "../model"; import type Request from "./request"; @@ -12,12 +13,16 @@ export class RequestQueue extends Set { private sendInProgress: boolean; private id: number; private deviceIeeeAddress: string; + // Declared without initializer so we can define it as non-enumerable below, + // keeping it out of deepClone/serialization. + private readonly enqueueTimes!: WeakMap; constructor(endpoint: Endpoint) { super(); this.sendInProgress = false; this.id = endpoint.ID; this.deviceIeeeAddress = endpoint.deviceIeeeAddress; + Object.defineProperty(this, "enqueueTimes", {value: new WeakMap(), enumerable: false}); } public async send(fastPolling: boolean): Promise { @@ -34,8 +39,13 @@ export class RequestQueue extends Set { for (const request of this) { if (now > request.expires) { logger.debug(`Request Queue (${this.deviceIeeeAddress}/${this.id}): discard after timeout. Size before: ${this.size}`, NS); + const enqueuedAt = this.enqueueTimes.get(request); request.reject(); this.delete(request); + metrics.requestQueueLength(this.deviceIeeeAddress, this.id, this.size); + if (enqueuedAt !== undefined) { + metrics.requestQueueDuration(this.deviceIeeeAddress, this.id, "expired", (now - enqueuedAt) / 1000); + } } } @@ -44,10 +54,15 @@ export class RequestQueue extends Set { for (const request of this) { if (fastPolling || request.sendPolicy !== "bulk") { try { + const enqueuedAt = this.enqueueTimes.get(request); const result = await request.send(); logger.debug(`Request Queue (${this.deviceIeeeAddress}/${this.id}): send success`, NS); request.resolve(result); this.delete(request); + metrics.requestQueueLength(this.deviceIeeeAddress, this.id, this.size); + if (enqueuedAt !== undefined) { + metrics.requestQueueDuration(this.deviceIeeeAddress, this.id, "sent", (Date.now() - enqueuedAt) / 1000); + } } catch (error) { logger.debug( `Request Queue (${this.deviceIeeeAddress}/${this.id}): send failed, expires in ` + @@ -65,6 +80,8 @@ export class RequestQueue extends Set { return await new Promise((resolve, reject): void => { request.addCallbacks(resolve, reject); this.add(request); + this.enqueueTimes.set(request, Date.now()); + metrics.requestQueueLength(this.deviceIeeeAddress, this.id, this.size); }); } diff --git a/src/index.ts b/src/index.ts index e331cc3051..946b5b1069 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,8 @@ export {getOtaFirmware, getOtaIndex, parseOtaHeader, parseOtaImage, parseOtaSubE export type * as Models from "./controller/model"; export type * as Types from "./controller/tstype"; export {setLogger} from "./utils/logger"; +export {setMetrics, noopMetrics} from "./utils/metrics"; +export type {Metrics} from "./utils/metrics"; export {getTimeClusterAttributes} from "./utils/timeService"; export * as ZSpec from "./zspec"; export * as Zcl from "./zspec/zcl"; diff --git a/src/utils/metrics.ts b/src/utils/metrics.ts new file mode 100644 index 0000000000..57dd06fc84 --- /dev/null +++ b/src/utils/metrics.ts @@ -0,0 +1,25 @@ +export interface Metrics { + adapterSendZclUnicast(ieeeAddr: string, status: "success" | "failure", durationSeconds: number): void; + adapterSendZdo(ieeeAddr: string, clusterId: number, status: "success" | "failure", durationSeconds: number): void; + adapterSendZclGroup(groupId: number, status: "success" | "failure", durationSeconds: number): void; + adapterSendZclBroadcast(status: "success" | "failure", durationSeconds: number): void; + adapterRetry(adapterType: string, ieeeAddr: string | undefined, reason: string): void; + requestQueueLength(ieeeAddr: string, endpointId: number, length: number): void; + requestQueueDuration(ieeeAddr: string, endpointId: number, outcome: "sent" | "expired", durationSeconds: number): void; +} + +export const noopMetrics: Metrics = { + adapterSendZclUnicast: () => {}, + adapterSendZdo: () => {}, + adapterSendZclGroup: () => {}, + adapterSendZclBroadcast: () => {}, + adapterRetry: () => {}, + requestQueueLength: () => {}, + requestQueueDuration: () => {}, +}; + +export let metrics: Metrics = noopMetrics; + +export function setMetrics(m: Metrics): void { + metrics = m; +} From 6d73abe4c94b09011622ad3d0fbcec645647265c Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Sun, 26 Apr 2026 21:46:34 +0100 Subject: [PATCH 02/10] fix: resolve biome 2.4.13 CI failures - Update biome.json schema to 2.4.13 - Fix import sort order in metricsAdapter.ts, controller.ts, index.ts - Replace banned Function type with (...args: never) => unknown - Reformat instrumentSend signature to single line per biome style Signed-off-by: Tom Wilkie --- src/adapter/metricsAdapter.ts | 9 +++------ src/controller/controller.ts | 2 +- src/index.ts | 2 +- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/adapter/metricsAdapter.ts b/src/adapter/metricsAdapter.ts index 233114376f..74b36e304e 100644 --- a/src/adapter/metricsAdapter.ts +++ b/src/adapter/metricsAdapter.ts @@ -1,13 +1,10 @@ -import type {Adapter} from "./adapter.js"; import {metrics} from "../utils/metrics.js"; +import type {Adapter} from "./adapter.js"; type SendStatus = "success" | "failure"; type AnyAsyncFn = (...args: unknown[]) => Promise; -function instrumentSend( - fn: AnyAsyncFn, - record: (args: unknown[], status: SendStatus, durationSeconds: number) => void, -): AnyAsyncFn { +function instrumentSend(fn: AnyAsyncFn, record: (args: unknown[], status: SendStatus, durationSeconds: number) => void): AnyAsyncFn { return async (...args) => { const start = Date.now(); try { @@ -58,7 +55,7 @@ export function wrapWithMetrics(adapter: Adapter): Adapter { return dispatch.get(prop); } const value = Reflect.get(target, prop, target); - return typeof value === "function" ? (value as Function).bind(target) : value; + return typeof value === "function" ? (value as (...args: never) => unknown).bind(target) : value; }, }); } diff --git a/src/controller/controller.ts b/src/controller/controller.ts index 2b06965ee3..0e00aeea31 100644 --- a/src/controller/controller.ts +++ b/src/controller/controller.ts @@ -2,8 +2,8 @@ import assert from "node:assert"; import events from "node:events"; import fs from "node:fs"; import {Adapter, type Events as AdapterEvents, type TsType as AdapterTsType} from "../adapter"; -import {wrapWithMetrics} from "../adapter/metricsAdapter"; import type {ZclPayload} from "../adapter/events"; +import {wrapWithMetrics} from "../adapter/metricsAdapter"; import {BackupUtils, wait} from "../utils"; import {logger} from "../utils/logger"; import {isNumberArrayOfLength} from "../utils/utils"; diff --git a/src/index.ts b/src/index.ts index 946b5b1069..089883384a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,8 +8,8 @@ export {getOtaFirmware, getOtaIndex, parseOtaHeader, parseOtaImage, parseOtaSubE export type * as Models from "./controller/model"; export type * as Types from "./controller/tstype"; export {setLogger} from "./utils/logger"; -export {setMetrics, noopMetrics} from "./utils/metrics"; export type {Metrics} from "./utils/metrics"; +export {noopMetrics, setMetrics} from "./utils/metrics"; export {getTimeClusterAttributes} from "./utils/timeService"; export * as ZSpec from "./zspec"; export * as Zcl from "./zspec/zcl"; From 4bb48af108c1fbaf3da72c06f737ef8a5a17065e Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Mon, 27 Apr 2026 13:06:39 +0100 Subject: [PATCH 03/10] feat: replace injectable Metrics with EventEmitter and make adapter wrapping opt-in Replaces the injectable `Metrics` interface and `setMetrics`/`noopMetrics` pattern with a typed `EventEmitter` singleton. Adapter metrics wrapping is now opt-in via `enableMetrics: true` in Controller options (defaults to false). Also instruments ZDO responses and ZCL payload receives, and adds tests for the new EventEmitter-based metrics events. Signed-off-by: Tom Wilkie --- src/adapter/ember/adapter/emberAdapter.ts | 4 +- src/adapter/metricsAdapter.ts | 19 ++-- src/adapter/z-stack/adapter/zStackAdapter.ts | 6 +- src/adapter/zboss/adapter/zbossAdapter.ts | 2 +- src/adapter/zigate/adapter/zigateAdapter.ts | 4 +- src/adapter/zoh/adapter/zohAdapter.ts | 2 +- src/controller/controller.ts | 14 ++- src/controller/helpers/requestQueue.ts | 10 +-- src/index.ts | 4 +- src/utils/metrics.ts | 93 ++++++++++++++----- test/controller.test.ts | 95 ++++++++++++++++++++ 11 files changed, 201 insertions(+), 52 deletions(-) diff --git a/src/adapter/ember/adapter/emberAdapter.ts b/src/adapter/ember/adapter/emberAdapter.ts index 4e8f80d580..aa7f2d2bb7 100644 --- a/src/adapter/ember/adapter/emberAdapter.ts +++ b/src/adapter/ember/adapter/emberAdapter.ts @@ -2008,10 +2008,10 @@ export class EmberAdapter extends Adapter { } if (status === SLStatus.ZIGBEE_MAX_MESSAGE_LIMIT_REACHED || status === SLStatus.BUSY) { - metrics.adapterRetry("ember", ieeeAddr, SLStatus[status] ?? String(status)); + metrics.emit("adapterRetry", {adapterType: "ember", ieeeAddr, reason: SLStatus[status] ?? String(status)}); await wait(QUEUE_BUSY_DEFER_MSEC); } else if (status === SLStatus.NETWORK_DOWN) { - metrics.adapterRetry("ember", ieeeAddr, "NETWORK_DOWN"); + metrics.emit("adapterRetry", {adapterType: "ember", ieeeAddr, reason: "NETWORK_DOWN"}); await wait(QUEUE_NETWORK_DOWN_DEFER_MSEC); } else { throw new Error( diff --git a/src/adapter/metricsAdapter.ts b/src/adapter/metricsAdapter.ts index 74b36e304e..f71a6014dc 100644 --- a/src/adapter/metricsAdapter.ts +++ b/src/adapter/metricsAdapter.ts @@ -1,7 +1,6 @@ -import {metrics} from "../utils/metrics.js"; +import {type SendStatus, metrics} from "../utils/metrics.js"; import type {Adapter} from "./adapter.js"; -type SendStatus = "success" | "failure"; type AnyAsyncFn = (...args: unknown[]) => Promise; function instrumentSend(fn: AnyAsyncFn, record: (args: unknown[], status: SendStatus, durationSeconds: number) => void): AnyAsyncFn { @@ -27,25 +26,27 @@ export function wrapWithMetrics(adapter: Adapter): Adapter { const dispatch = new Map([ [ "sendZclFrameToEndpoint", - instrumentSend(asFn(adapter.sendZclFrameToEndpoint.bind(adapter)), ([ieeeAddr], status, dur) => - metrics.adapterSendZclUnicast(ieeeAddr as string, status, dur), + instrumentSend(asFn(adapter.sendZclFrameToEndpoint.bind(adapter)), ([ieeeAddr], status, durationSeconds) => + metrics.emit("adapterSendZclUnicast", {ieeeAddr: ieeeAddr as string, status, durationSeconds}), ), ], [ "sendZdo", - instrumentSend(asFn(adapter.sendZdo.bind(adapter)), ([ieeeAddr, , clusterId], status, dur) => - metrics.adapterSendZdo(ieeeAddr as string, clusterId as number, status, dur), + instrumentSend(asFn(adapter.sendZdo.bind(adapter)), ([ieeeAddr, , clusterId], status, durationSeconds) => + metrics.emit("adapterSendZdo", {ieeeAddr: ieeeAddr as string, clusterId: clusterId as number, status, durationSeconds}), ), ], [ "sendZclFrameToGroup", - instrumentSend(asFn(adapter.sendZclFrameToGroup.bind(adapter)), ([groupId], status, dur) => - metrics.adapterSendZclGroup(groupId as number, status, dur), + instrumentSend(asFn(adapter.sendZclFrameToGroup.bind(adapter)), ([groupId], status, durationSeconds) => + metrics.emit("adapterSendZclGroup", {groupId: groupId as number, status, durationSeconds}), ), ], [ "sendZclFrameToAll", - instrumentSend(asFn(adapter.sendZclFrameToAll.bind(adapter)), (_args, status, dur) => metrics.adapterSendZclBroadcast(status, dur)), + instrumentSend(asFn(adapter.sendZclFrameToAll.bind(adapter)), (_args, status, durationSeconds) => + metrics.emit("adapterSendZclBroadcast", {status, durationSeconds}), + ), ], ]); diff --git a/src/adapter/z-stack/adapter/zStackAdapter.ts b/src/adapter/z-stack/adapter/zStackAdapter.ts index 921feb425c..bffceea2ec 100644 --- a/src/adapter/z-stack/adapter/zStackAdapter.ts +++ b/src/adapter/z-stack/adapter/zStackAdapter.ts @@ -585,7 +585,7 @@ export class ZStackAdapter extends Adapter { * MAC_NO_RESOURCES: Operation could not be completed because no memory resources are available, * wait some time and retry. */ - metrics.adapterRetry("zstack", ieeeAddr, ZnpCommandStatus[dataConfirmResult] ?? String(dataConfirmResult)); + metrics.emit("adapterRetry", {adapterType: "zstack", ieeeAddr, reason: ZnpCommandStatus[dataConfirmResult] ?? String(dataConfirmResult)}); await wait(2000); return await this.sendZclFrameToEndpointInternal( ieeeAddr, @@ -667,7 +667,7 @@ export class ZStackAdapter extends Adapter { await wait(2000); } - metrics.adapterRetry("zstack", ieeeAddr, ZnpCommandStatus[dataConfirmResult] ?? String(dataConfirmResult)); + metrics.emit("adapterRetry", {adapterType: "zstack", ieeeAddr, reason: ZnpCommandStatus[dataConfirmResult] ?? String(dataConfirmResult)}); return await this.sendZclFrameToEndpointInternal( ieeeAddr, networkAddress, @@ -719,7 +719,7 @@ export class ZStackAdapter extends Adapter { }); } // No response could be of invalid route, e.g. when message is send to wrong parent of end device. - metrics.adapterRetry("zstack", ieeeAddr, "no_response"); + metrics.emit("adapterRetry", {adapterType: "zstack", ieeeAddr, reason: "no_response"}); await this.discoverRoute(networkAddress); return await this.sendZclFrameToEndpointInternal( ieeeAddr, diff --git a/src/adapter/zboss/adapter/zbossAdapter.ts b/src/adapter/zboss/adapter/zbossAdapter.ts index a7caa83edd..3dfaa09222 100644 --- a/src/adapter/zboss/adapter/zbossAdapter.ts +++ b/src/adapter/zboss/adapter/zbossAdapter.ts @@ -388,7 +388,7 @@ export class ZBOSSAdapter extends Adapter { } catch (error) { logger.debug(`Response timeout (${ieeeAddr}:${networkAddress},${responseAttempt})`, NS); if (responseAttempt < 1 && !disableRecovery) { - metrics.adapterRetry("zboss", ieeeAddr, "no_response"); + metrics.emit("adapterRetry", {adapterType: "zboss", ieeeAddr, reason: "no_response"}); return await this.sendZclFrameToEndpointInternal( ieeeAddr, networkAddress, diff --git a/src/adapter/zigate/adapter/zigateAdapter.ts b/src/adapter/zigate/adapter/zigateAdapter.ts index 7fa207a60d..e919b84324 100644 --- a/src/adapter/zigate/adapter/zigateAdapter.ts +++ b/src/adapter/zigate/adapter/zigateAdapter.ts @@ -369,7 +369,7 @@ export class ZiGateAdapter extends Adapter { } catch { if (responseAttempt < 1 && !disableRecovery) { // @todo discover route - metrics.adapterRetry("zigate", ieeeAddr, "send_failure"); + metrics.emit("adapterRetry", {adapterType: "zigate", ieeeAddr, reason: "send_failure"}); return await this.sendZclFrameToEndpointInternal( ieeeAddr, networkAddress, @@ -397,7 +397,7 @@ export class ZiGateAdapter extends Adapter { } catch (error) { logger.error(`Response error ${(error as Error).message} (${ieeeAddr}:${networkAddress},${responseAttempt})`, NS); if (responseAttempt < 1 && !disableRecovery) { - metrics.adapterRetry("zigate", ieeeAddr, "no_response"); + metrics.emit("adapterRetry", {adapterType: "zigate", ieeeAddr, reason: "no_response"}); return await this.sendZclFrameToEndpointInternal( ieeeAddr, networkAddress, diff --git a/src/adapter/zoh/adapter/zohAdapter.ts b/src/adapter/zoh/adapter/zohAdapter.ts index c9c8fb9645..0bc24f5d29 100644 --- a/src/adapter/zoh/adapter/zohAdapter.ts +++ b/src/adapter/zoh/adapter/zohAdapter.ts @@ -647,7 +647,7 @@ export class ZoHAdapter extends Adapter { if (disableRecovery || i === 1) { throw error; } - metrics.adapterRetry("zoh", ieeeAddr, "send_failure"); + metrics.emit("adapterRetry", {adapterType: "zoh", ieeeAddr, reason: "send_failure"}); } /* v8 ignore start */ } // coverage detection failure diff --git a/src/controller/controller.ts b/src/controller/controller.ts index 0e00aeea31..284e1197fc 100644 --- a/src/controller/controller.ts +++ b/src/controller/controller.ts @@ -4,6 +4,7 @@ import fs from "node:fs"; import {Adapter, type Events as AdapterEvents, type TsType as AdapterTsType} from "../adapter"; import type {ZclPayload} from "../adapter/events"; import {wrapWithMetrics} from "../adapter/metricsAdapter"; +import {metrics} from "../utils/metrics.js"; import {BackupUtils, wait} from "../utils"; import {logger} from "../utils/logger"; import {isNumberArrayOfLength} from "../utils/utils"; @@ -36,6 +37,7 @@ interface Options { databaseBackupPath: string; backupPath: string; adapter: AdapterTsType.AdapterOptions; + enableMetrics?: boolean; /** * This lambda can be used by an application to explictly reject or accept an incoming device. * When false is returned zigbee-herdsman will not start the interview process and immidiately @@ -138,9 +140,8 @@ export class Controller extends events.EventEmitter { Entity.injectDatabase(this.database); // Adapter (create and inject) - this.adapter = wrapWithMetrics( - await Adapter.create(this.options.network, this.options.serialPort, this.options.backupPath, this.options.adapter), - ); + const rawAdapter = await Adapter.create(this.options.network, this.options.serialPort, this.options.backupPath, this.options.adapter); + this.adapter = this.options.enableMetrics ? wrapWithMetrics(rawAdapter) : rawAdapter; abortSignal?.throwIfAborted(); @@ -907,6 +908,7 @@ export class Controller extends events.EventEmitter { } private onZdoResponse(clusterId: Zdo.ClusterId, response: ZdoTypes.GenericZdoResponse): void { + metrics.emit("adapterReceiveZdoResponse", {clusterId}); logger.debug( `Received ZDO response: clusterId=${Zdo.ClusterId[clusterId]}, status=${Zdo.Status[response[0]]}, payload=${JSON.stringify(response[1])}`, NS, @@ -946,6 +948,12 @@ export class Controller extends events.EventEmitter { return; } + metrics.emit("adapterReceiveZclPayload", { + ieeeAddr: typeof payload.address === "string" ? payload.address : undefined, + clusterID: payload.clusterID, + wasBroadcast: payload.wasBroadcast, + }); + if (payload.clusterID === Zcl.Clusters.greenPower.ID) { try { // Custom clusters are not supported for Green Power since we need to parse the frame to get the device. diff --git a/src/controller/helpers/requestQueue.ts b/src/controller/helpers/requestQueue.ts index 7050d3a406..66325d7526 100755 --- a/src/controller/helpers/requestQueue.ts +++ b/src/controller/helpers/requestQueue.ts @@ -42,9 +42,9 @@ export class RequestQueue extends Set { const enqueuedAt = this.enqueueTimes.get(request); request.reject(); this.delete(request); - metrics.requestQueueLength(this.deviceIeeeAddress, this.id, this.size); + metrics.emit("requestQueueLength", {ieeeAddr: this.deviceIeeeAddress, endpointId: this.id, length: this.size}); if (enqueuedAt !== undefined) { - metrics.requestQueueDuration(this.deviceIeeeAddress, this.id, "expired", (now - enqueuedAt) / 1000); + metrics.emit("requestQueueDuration", {ieeeAddr: this.deviceIeeeAddress, endpointId: this.id, outcome: "expired", durationSeconds: (now - enqueuedAt) / 1000}); } } } @@ -59,9 +59,9 @@ export class RequestQueue extends Set { logger.debug(`Request Queue (${this.deviceIeeeAddress}/${this.id}): send success`, NS); request.resolve(result); this.delete(request); - metrics.requestQueueLength(this.deviceIeeeAddress, this.id, this.size); + metrics.emit("requestQueueLength", {ieeeAddr: this.deviceIeeeAddress, endpointId: this.id, length: this.size}); if (enqueuedAt !== undefined) { - metrics.requestQueueDuration(this.deviceIeeeAddress, this.id, "sent", (Date.now() - enqueuedAt) / 1000); + metrics.emit("requestQueueDuration", {ieeeAddr: this.deviceIeeeAddress, endpointId: this.id, outcome: "sent", durationSeconds: (Date.now() - enqueuedAt) / 1000}); } } catch (error) { logger.debug( @@ -81,7 +81,7 @@ export class RequestQueue extends Set { request.addCallbacks(resolve, reject); this.add(request); this.enqueueTimes.set(request, Date.now()); - metrics.requestQueueLength(this.deviceIeeeAddress, this.id, this.size); + metrics.emit("requestQueueLength", {ieeeAddr: this.deviceIeeeAddress, endpointId: this.id, length: this.size}); }); } diff --git a/src/index.ts b/src/index.ts index 089883384a..251d3024ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,8 +8,8 @@ export {getOtaFirmware, getOtaIndex, parseOtaHeader, parseOtaImage, parseOtaSubE export type * as Models from "./controller/model"; export type * as Types from "./controller/tstype"; export {setLogger} from "./utils/logger"; -export type {Metrics} from "./utils/metrics"; -export {noopMetrics, setMetrics} from "./utils/metrics"; +export type {MetricsEventMap} from "./utils/metrics"; +export {metrics} from "./utils/metrics"; export {getTimeClusterAttributes} from "./utils/timeService"; export * as ZSpec from "./zspec"; export * as Zcl from "./zspec/zcl"; diff --git a/src/utils/metrics.ts b/src/utils/metrics.ts index 57dd06fc84..ce1faf7d91 100644 --- a/src/utils/metrics.ts +++ b/src/utils/metrics.ts @@ -1,25 +1,70 @@ -export interface Metrics { - adapterSendZclUnicast(ieeeAddr: string, status: "success" | "failure", durationSeconds: number): void; - adapterSendZdo(ieeeAddr: string, clusterId: number, status: "success" | "failure", durationSeconds: number): void; - adapterSendZclGroup(groupId: number, status: "success" | "failure", durationSeconds: number): void; - adapterSendZclBroadcast(status: "success" | "failure", durationSeconds: number): void; - adapterRetry(adapterType: string, ieeeAddr: string | undefined, reason: string): void; - requestQueueLength(ieeeAddr: string, endpointId: number, length: number): void; - requestQueueDuration(ieeeAddr: string, endpointId: number, outcome: "sent" | "expired", durationSeconds: number): void; -} - -export const noopMetrics: Metrics = { - adapterSendZclUnicast: () => {}, - adapterSendZdo: () => {}, - adapterSendZclGroup: () => {}, - adapterSendZclBroadcast: () => {}, - adapterRetry: () => {}, - requestQueueLength: () => {}, - requestQueueDuration: () => {}, -}; - -export let metrics: Metrics = noopMetrics; - -export function setMetrics(m: Metrics): void { - metrics = m; +import {EventEmitter} from "node:events"; + +export type SendStatus = "success" | "failure"; + +export interface AdapterSendZclUnicastPayload { + ieeeAddr: string; + status: SendStatus; + durationSeconds: number; +} + +export interface AdapterSendZdoPayload { + ieeeAddr: string; + clusterId: number; + status: SendStatus; + durationSeconds: number; +} + +export interface AdapterSendZclGroupPayload { + groupId: number; + status: SendStatus; + durationSeconds: number; } + +export interface AdapterSendZclBroadcastPayload { + status: SendStatus; + durationSeconds: number; +} + +export interface AdapterRetryPayload { + adapterType: string; + ieeeAddr: string | undefined; + reason: string; +} + +export interface AdapterReceiveZclPayloadPayload { + ieeeAddr: string | undefined; + clusterID: number; + wasBroadcast: boolean; +} + +export interface AdapterReceiveZdoResponsePayload { + clusterId: number; +} + +export interface RequestQueueLengthPayload { + ieeeAddr: string; + endpointId: number; + length: number; +} + +export interface RequestQueueDurationPayload { + ieeeAddr: string; + endpointId: number; + outcome: "sent" | "expired"; + durationSeconds: number; +} + +export interface MetricsEventMap { + adapterSendZclUnicast: [data: AdapterSendZclUnicastPayload]; + adapterSendZdo: [data: AdapterSendZdoPayload]; + adapterSendZclGroup: [data: AdapterSendZclGroupPayload]; + adapterSendZclBroadcast: [data: AdapterSendZclBroadcastPayload]; + adapterRetry: [data: AdapterRetryPayload]; + adapterReceiveZclPayload: [data: AdapterReceiveZclPayloadPayload]; + adapterReceiveZdoResponse: [data: AdapterReceiveZdoResponsePayload]; + requestQueueLength: [data: RequestQueueLengthPayload]; + requestQueueDuration: [data: RequestQueueDurationPayload]; +} + +export const metrics = new EventEmitter(); diff --git a/test/controller.test.ts b/test/controller.test.ts index a67c37d34e..1835e73357 100755 --- a/test/controller.test.ts +++ b/test/controller.test.ts @@ -21,6 +21,7 @@ import * as Zcl from "../src/zspec/zcl"; import type {CustomClusters} from "../src/zspec/zcl/definition/tstype"; import * as Zdo from "../src/zspec/zdo"; import type {IEEEAddressResponse, NetworkAddressResponse} from "../src/zspec/zdo/definition/tstypes"; +import {metrics} from "../src/utils/metrics"; import {DEFAULT_184_CHECKIN_INTERVAL, LQI_TABLE_ENTRY_DEFAULTS, MOCK_DEVICES, ROUTING_TABLE_ENTRY_DEFAULTS} from "./mockDevices"; const globalSetImmediate = setImmediate; @@ -10608,4 +10609,98 @@ describe("Controller", () => { expect(zclCommandSpy).toHaveBeenCalledTimes(0); }); + + describe("Metrics", () => { + it("emits adapterReceiveZclPayload with ieeeAddr when address is a string", async () => { + const received: Parameters>[1]>[0][] = []; + metrics.on("adapterReceiveZclPayload", (data) => received.push(data)); + const frame = Zcl.Frame.create(0, 1, true, undefined, 10, "readRsp", 0, [{attrId: 5, status: 0, dataType: 66, attrData: "test"}], {}); + await controller.start(); + await mockAdapterEvents.zclPayload({ + wasBroadcast: false, + address: "0x129", + clusterID: frame.cluster.ID, + data: frame.toBuffer(), + header: frame.header, + endpoint: 1, + linkquality: 50, + groupID: 1, + destinationEndpoint: 1, + }); + metrics.removeAllListeners("adapterReceiveZclPayload"); + expect(received).toHaveLength(1); + expect(received[0]).toEqual({ieeeAddr: "0x129", clusterID: frame.cluster.ID, wasBroadcast: false}); + }); + + it("emits adapterReceiveZclPayload with ieeeAddr undefined when address is a number", async () => { + const received: Parameters>[1]>[0][] = []; + metrics.on("adapterReceiveZclPayload", (data) => received.push(data)); + const frame = Zcl.Frame.create(0, 1, true, undefined, 10, "readRsp", 0, [{attrId: 5, status: 0, dataType: 66, attrData: "test"}], {}); + await controller.start(); + await mockAdapterEvents.zclPayload({ + wasBroadcast: true, + address: 129, + clusterID: frame.cluster.ID, + data: frame.toBuffer(), + header: frame.header, + endpoint: 1, + linkquality: 50, + groupID: 1, + destinationEndpoint: 1, + }); + metrics.removeAllListeners("adapterReceiveZclPayload"); + expect(received).toHaveLength(1); + expect(received[0]).toEqual({ieeeAddr: undefined, clusterID: frame.cluster.ID, wasBroadcast: true}); + }); + + it("does not emit adapterReceiveZclPayload for touchlink cluster", async () => { + const received: unknown[] = []; + metrics.on("adapterReceiveZclPayload", (data) => received.push(data)); + await controller.start(); + await mockAdapterEvents.zclPayload({ + wasBroadcast: false, + address: "0x129", + clusterID: Zcl.Clusters.touchlink.ID, + data: Buffer.from([]), + header: undefined, + endpoint: 1, + linkquality: 50, + groupID: 1, + destinationEndpoint: 1, + }); + metrics.removeAllListeners("adapterReceiveZclPayload"); + expect(received).toHaveLength(0); + }); + + it("emits adapterReceiveZdoResponse with clusterId", async () => { + const received: Parameters>[1]>[0][] = []; + metrics.on("adapterReceiveZdoResponse", (data) => received.push(data)); + await controller.start(); + await mockAdapterEvents.zdoResponse(Zdo.ClusterId.END_DEVICE_ANNOUNCE, [ + Zdo.Status.SUCCESS, + {nwkAddress: 129, eui64: "0x129", capabilities: Zdo.Utils.getMacCapFlags(0x10)}, + ]); + metrics.removeAllListeners("adapterReceiveZdoResponse"); + expect(received).toHaveLength(1); + expect(received[0]).toEqual({clusterId: Zdo.ClusterId.END_DEVICE_ANNOUNCE}); + }); + + it("emits adapterReceiveZdoResponse for each ZDO cluster received", async () => { + const received: Parameters>[1]>[0][] = []; + metrics.on("adapterReceiveZdoResponse", (data) => received.push(data)); + await controller.start(); + await mockAdapterEvents.zdoResponse(Zdo.ClusterId.NETWORK_ADDRESS_RESPONSE, [ + Zdo.Status.SUCCESS, + {nwkAddress: 129, eui64: "0x129", startIndex: 0, assocDevList: []}, + ]); + await mockAdapterEvents.zdoResponse(Zdo.ClusterId.IEEE_ADDRESS_RESPONSE, [ + Zdo.Status.SUCCESS, + {nwkAddress: 129, eui64: "0x129", startIndex: 0, assocDevList: []}, + ]); + metrics.removeAllListeners("adapterReceiveZdoResponse"); + expect(received).toHaveLength(2); + expect(received[0]).toEqual({clusterId: Zdo.ClusterId.NETWORK_ADDRESS_RESPONSE}); + expect(received[1]).toEqual({clusterId: Zdo.ClusterId.IEEE_ADDRESS_RESPONSE}); + }); + }); }); From 3c50dad995e4538198d79efdad2da45edfda7841 Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Mon, 27 Apr 2026 16:29:39 +0100 Subject: [PATCH 04/10] Remove unnecessary change Signed-off-by: Tom Wilkie --- src/adapter/zoh/adapter/zohAdapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapter/zoh/adapter/zohAdapter.ts b/src/adapter/zoh/adapter/zohAdapter.ts index 0bc24f5d29..5f294d4b0d 100644 --- a/src/adapter/zoh/adapter/zohAdapter.ts +++ b/src/adapter/zoh/adapter/zohAdapter.ts @@ -646,7 +646,7 @@ export class ZoHAdapter extends Adapter { } catch (error) { if (disableRecovery || i === 1) { throw error; - } + } // else retry metrics.emit("adapterRetry", {adapterType: "zoh", ieeeAddr, reason: "send_failure"}); } /* v8 ignore start */ From 9417931bfa5afd2454dc581ca8f959d6405f9eb1 Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Mon, 27 Apr 2026 16:37:00 +0100 Subject: [PATCH 05/10] refactor: move enqueueTime from WeakMap into Request property Simpler and more cohesive to store enqueuedAt on the Request itself, alongside the existing expires timestamp, rather than in a parallel non-enumerable WeakMap on RequestQueue. Signed-off-by: Tom Wilkie --- src/controller/helpers/request.ts | 2 ++ src/controller/helpers/requestQueue.ts | 16 +++++----------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/controller/helpers/request.ts b/src/controller/helpers/request.ts index 776fe41896..57e8f4c165 100644 --- a/src/controller/helpers/request.ts +++ b/src/controller/helpers/request.ts @@ -32,6 +32,7 @@ export class Request { private func: () => Promise; frame: Zcl.Frame; expires: number; + enqueuedAt: number | undefined; sendPolicy: SendPolicy | undefined; private resolveQueue: Array<(value: Type) => void>; private rejectQueue: Array<(error: Error) => void>; @@ -49,6 +50,7 @@ export class Request { this.func = func; this.frame = frame; this.expires = timeout + Date.now(); + this.enqueuedAt = undefined; this.sendPolicy = sendPolicy ?? (!frame.command ? undefined : Request.defaultSendPolicy[frame.command.ID]); this.resolveQueue = resolve === undefined ? ([] as ((value: Type) => void)[]) : new Array<(value: Type) => void>(resolve); this.rejectQueue = reject === undefined ? ([] as ((error: Error) => void)[]) : new Array<(error: Error) => void>(reject); diff --git a/src/controller/helpers/requestQueue.ts b/src/controller/helpers/requestQueue.ts index 66325d7526..e34a407766 100755 --- a/src/controller/helpers/requestQueue.ts +++ b/src/controller/helpers/requestQueue.ts @@ -13,16 +13,12 @@ export class RequestQueue extends Set { private sendInProgress: boolean; private id: number; private deviceIeeeAddress: string; - // Declared without initializer so we can define it as non-enumerable below, - // keeping it out of deepClone/serialization. - private readonly enqueueTimes!: WeakMap; constructor(endpoint: Endpoint) { super(); this.sendInProgress = false; this.id = endpoint.ID; this.deviceIeeeAddress = endpoint.deviceIeeeAddress; - Object.defineProperty(this, "enqueueTimes", {value: new WeakMap(), enumerable: false}); } public async send(fastPolling: boolean): Promise { @@ -39,12 +35,11 @@ export class RequestQueue extends Set { for (const request of this) { if (now > request.expires) { logger.debug(`Request Queue (${this.deviceIeeeAddress}/${this.id}): discard after timeout. Size before: ${this.size}`, NS); - const enqueuedAt = this.enqueueTimes.get(request); request.reject(); this.delete(request); metrics.emit("requestQueueLength", {ieeeAddr: this.deviceIeeeAddress, endpointId: this.id, length: this.size}); - if (enqueuedAt !== undefined) { - metrics.emit("requestQueueDuration", {ieeeAddr: this.deviceIeeeAddress, endpointId: this.id, outcome: "expired", durationSeconds: (now - enqueuedAt) / 1000}); + if (request.enqueuedAt !== undefined) { + metrics.emit("requestQueueDuration", {ieeeAddr: this.deviceIeeeAddress, endpointId: this.id, outcome: "expired", durationSeconds: (now - request.enqueuedAt) / 1000}); } } } @@ -54,14 +49,13 @@ export class RequestQueue extends Set { for (const request of this) { if (fastPolling || request.sendPolicy !== "bulk") { try { - const enqueuedAt = this.enqueueTimes.get(request); const result = await request.send(); logger.debug(`Request Queue (${this.deviceIeeeAddress}/${this.id}): send success`, NS); request.resolve(result); this.delete(request); metrics.emit("requestQueueLength", {ieeeAddr: this.deviceIeeeAddress, endpointId: this.id, length: this.size}); - if (enqueuedAt !== undefined) { - metrics.emit("requestQueueDuration", {ieeeAddr: this.deviceIeeeAddress, endpointId: this.id, outcome: "sent", durationSeconds: (Date.now() - enqueuedAt) / 1000}); + if (request.enqueuedAt !== undefined) { + metrics.emit("requestQueueDuration", {ieeeAddr: this.deviceIeeeAddress, endpointId: this.id, outcome: "sent", durationSeconds: (Date.now() - request.enqueuedAt) / 1000}); } } catch (error) { logger.debug( @@ -80,7 +74,7 @@ export class RequestQueue extends Set { return await new Promise((resolve, reject): void => { request.addCallbacks(resolve, reject); this.add(request); - this.enqueueTimes.set(request, Date.now()); + request.enqueuedAt = Date.now(); metrics.emit("requestQueueLength", {ieeeAddr: this.deviceIeeeAddress, endpointId: this.id, length: this.size}); }); } From 6a6697ae985f4e0abe34df50cbdebadb31ca7fdf Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Mon, 27 Apr 2026 16:39:22 +0100 Subject: [PATCH 06/10] fix: resolve biome formatting and import sort CI failures Signed-off-by: Tom Wilkie --- src/adapter/metricsAdapter.ts | 2 +- src/adapter/z-stack/adapter/zStackAdapter.ts | 6 +++++- src/controller/controller.ts | 2 +- src/controller/helpers/requestQueue.ts | 14 ++++++++++++-- test/controller.test.ts | 2 +- 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/adapter/metricsAdapter.ts b/src/adapter/metricsAdapter.ts index f71a6014dc..229591de0c 100644 --- a/src/adapter/metricsAdapter.ts +++ b/src/adapter/metricsAdapter.ts @@ -1,4 +1,4 @@ -import {type SendStatus, metrics} from "../utils/metrics.js"; +import {metrics, type SendStatus} from "../utils/metrics.js"; import type {Adapter} from "./adapter.js"; type AnyAsyncFn = (...args: unknown[]) => Promise; diff --git a/src/adapter/z-stack/adapter/zStackAdapter.ts b/src/adapter/z-stack/adapter/zStackAdapter.ts index bffceea2ec..0226996710 100644 --- a/src/adapter/z-stack/adapter/zStackAdapter.ts +++ b/src/adapter/z-stack/adapter/zStackAdapter.ts @@ -585,7 +585,11 @@ export class ZStackAdapter extends Adapter { * MAC_NO_RESOURCES: Operation could not be completed because no memory resources are available, * wait some time and retry. */ - metrics.emit("adapterRetry", {adapterType: "zstack", ieeeAddr, reason: ZnpCommandStatus[dataConfirmResult] ?? String(dataConfirmResult)}); + metrics.emit("adapterRetry", { + adapterType: "zstack", + ieeeAddr, + reason: ZnpCommandStatus[dataConfirmResult] ?? String(dataConfirmResult), + }); await wait(2000); return await this.sendZclFrameToEndpointInternal( ieeeAddr, diff --git a/src/controller/controller.ts b/src/controller/controller.ts index 284e1197fc..b47a01bc4d 100644 --- a/src/controller/controller.ts +++ b/src/controller/controller.ts @@ -4,9 +4,9 @@ import fs from "node:fs"; import {Adapter, type Events as AdapterEvents, type TsType as AdapterTsType} from "../adapter"; import type {ZclPayload} from "../adapter/events"; import {wrapWithMetrics} from "../adapter/metricsAdapter"; -import {metrics} from "../utils/metrics.js"; import {BackupUtils, wait} from "../utils"; import {logger} from "../utils/logger"; +import {metrics} from "../utils/metrics.js"; import {isNumberArrayOfLength} from "../utils/utils"; import * as ZSpec from "../zspec"; import type {Eui64} from "../zspec/tstypes"; diff --git a/src/controller/helpers/requestQueue.ts b/src/controller/helpers/requestQueue.ts index e34a407766..d942fb776f 100755 --- a/src/controller/helpers/requestQueue.ts +++ b/src/controller/helpers/requestQueue.ts @@ -39,7 +39,12 @@ export class RequestQueue extends Set { this.delete(request); metrics.emit("requestQueueLength", {ieeeAddr: this.deviceIeeeAddress, endpointId: this.id, length: this.size}); if (request.enqueuedAt !== undefined) { - metrics.emit("requestQueueDuration", {ieeeAddr: this.deviceIeeeAddress, endpointId: this.id, outcome: "expired", durationSeconds: (now - request.enqueuedAt) / 1000}); + metrics.emit("requestQueueDuration", { + ieeeAddr: this.deviceIeeeAddress, + endpointId: this.id, + outcome: "expired", + durationSeconds: (now - request.enqueuedAt) / 1000, + }); } } } @@ -55,7 +60,12 @@ export class RequestQueue extends Set { this.delete(request); metrics.emit("requestQueueLength", {ieeeAddr: this.deviceIeeeAddress, endpointId: this.id, length: this.size}); if (request.enqueuedAt !== undefined) { - metrics.emit("requestQueueDuration", {ieeeAddr: this.deviceIeeeAddress, endpointId: this.id, outcome: "sent", durationSeconds: (Date.now() - request.enqueuedAt) / 1000}); + metrics.emit("requestQueueDuration", { + ieeeAddr: this.deviceIeeeAddress, + endpointId: this.id, + outcome: "sent", + durationSeconds: (Date.now() - request.enqueuedAt) / 1000, + }); } } catch (error) { logger.debug( diff --git a/test/controller.test.ts b/test/controller.test.ts index 1835e73357..24d81d54e2 100755 --- a/test/controller.test.ts +++ b/test/controller.test.ts @@ -14,6 +14,7 @@ import type {TCustomCluster} from "../src/controller/tstype"; import type * as Models from "../src/models"; import * as Utils from "../src/utils"; import {setLogger} from "../src/utils/logger"; +import {metrics} from "../src/utils/metrics"; import * as timeService from "../src/utils/timeService"; import * as ZSpec from "../src/zspec"; import {BroadcastAddress} from "../src/zspec/enums"; @@ -21,7 +22,6 @@ import * as Zcl from "../src/zspec/zcl"; import type {CustomClusters} from "../src/zspec/zcl/definition/tstype"; import * as Zdo from "../src/zspec/zdo"; import type {IEEEAddressResponse, NetworkAddressResponse} from "../src/zspec/zdo/definition/tstypes"; -import {metrics} from "../src/utils/metrics"; import {DEFAULT_184_CHECKIN_INTERVAL, LQI_TABLE_ENTRY_DEFAULTS, MOCK_DEVICES, ROUTING_TABLE_ENTRY_DEFAULTS} from "./mockDevices"; const globalSetImmediate = setImmediate; From 6519ade6713781fe84bae0f8eea08901790568d6 Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Mon, 27 Apr 2026 16:51:48 +0100 Subject: [PATCH 07/10] fix: add 100% coverage for metricsAdapter and fix branch coverage gaps Signed-off-by: Tom Wilkie --- src/adapter/ember/adapter/emberAdapter.ts | 1 + src/adapter/z-stack/adapter/zStackAdapter.ts | 2 + test/adapter/metricsAdapter.test.ts | 118 +++++++++++++++++++ test/controller.test.ts | 9 ++ 4 files changed, 130 insertions(+) create mode 100644 test/adapter/metricsAdapter.test.ts diff --git a/src/adapter/ember/adapter/emberAdapter.ts b/src/adapter/ember/adapter/emberAdapter.ts index aa7f2d2bb7..135b0825f0 100644 --- a/src/adapter/ember/adapter/emberAdapter.ts +++ b/src/adapter/ember/adapter/emberAdapter.ts @@ -2008,6 +2008,7 @@ export class EmberAdapter extends Adapter { } if (status === SLStatus.ZIGBEE_MAX_MESSAGE_LIMIT_REACHED || status === SLStatus.BUSY) { + /* v8 ignore next */ metrics.emit("adapterRetry", {adapterType: "ember", ieeeAddr, reason: SLStatus[status] ?? String(status)}); await wait(QUEUE_BUSY_DEFER_MSEC); } else if (status === SLStatus.NETWORK_DOWN) { diff --git a/src/adapter/z-stack/adapter/zStackAdapter.ts b/src/adapter/z-stack/adapter/zStackAdapter.ts index 0226996710..9091568ec6 100644 --- a/src/adapter/z-stack/adapter/zStackAdapter.ts +++ b/src/adapter/z-stack/adapter/zStackAdapter.ts @@ -588,6 +588,7 @@ export class ZStackAdapter extends Adapter { metrics.emit("adapterRetry", { adapterType: "zstack", ieeeAddr, + /* v8 ignore next */ reason: ZnpCommandStatus[dataConfirmResult] ?? String(dataConfirmResult), }); await wait(2000); @@ -671,6 +672,7 @@ export class ZStackAdapter extends Adapter { await wait(2000); } + /* v8 ignore next */ metrics.emit("adapterRetry", {adapterType: "zstack", ieeeAddr, reason: ZnpCommandStatus[dataConfirmResult] ?? String(dataConfirmResult)}); return await this.sendZclFrameToEndpointInternal( ieeeAddr, diff --git a/test/adapter/metricsAdapter.test.ts b/test/adapter/metricsAdapter.test.ts new file mode 100644 index 0000000000..4dd15949a1 --- /dev/null +++ b/test/adapter/metricsAdapter.test.ts @@ -0,0 +1,118 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; +import type {Adapter} from "../../src/adapter/adapter.js"; +import {wrapWithMetrics} from "../../src/adapter/metricsAdapter.js"; +import {metrics} from "../../src/utils/metrics.js"; + +function makeMockAdapter(overrides: Partial unknown>> = {}): Adapter { + return { + sendZclFrameToEndpoint: vi.fn().mockResolvedValue(undefined), + sendZdo: vi.fn().mockResolvedValue(undefined), + sendZclFrameToGroup: vi.fn().mockResolvedValue(undefined), + sendZclFrameToAll: vi.fn().mockResolvedValue(undefined), + someOtherMethod: vi.fn().mockReturnValue(42), + someProperty: "prop-value", + ...overrides, + } as unknown as Adapter; +} + +describe("wrapWithMetrics", () => { + let emitSpy: ReturnType; + + beforeEach(() => { + emitSpy = vi.spyOn(metrics, "emit"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("proxies non-dispatch methods and properties directly", () => { + const adapter = makeMockAdapter(); + const wrapped = wrapWithMetrics(adapter); + + // @ts-expect-error test property access + expect(wrapped.someProperty).toBe("prop-value"); + // @ts-expect-error test method access + expect(wrapped.someOtherMethod()).toBe(42); + }); + + describe("sendZclFrameToEndpoint", () => { + it("emits adapterSendZclUnicast on success", async () => { + const adapter = makeMockAdapter(); + const wrapped = wrapWithMetrics(adapter); + + await wrapped.sendZclFrameToEndpoint("0x1234", 1, 1, {} as never, 10000); + + expect(emitSpy).toHaveBeenCalledWith("adapterSendZclUnicast", expect.objectContaining({ieeeAddr: "0x1234", status: "success"})); + }); + + it("emits adapterSendZclUnicast on failure and re-throws", async () => { + const error = new Error("send failed"); + const adapter = makeMockAdapter({sendZclFrameToEndpoint: vi.fn().mockRejectedValue(error)}); + const wrapped = wrapWithMetrics(adapter); + + await expect(wrapped.sendZclFrameToEndpoint("0x1234", 1, 1, {} as never, 10000)).rejects.toThrow("send failed"); + expect(emitSpy).toHaveBeenCalledWith("adapterSendZclUnicast", expect.objectContaining({ieeeAddr: "0x1234", status: "failure"})); + }); + }); + + describe("sendZdo", () => { + it("emits adapterSendZdo on success", async () => { + const adapter = makeMockAdapter(); + const wrapped = wrapWithMetrics(adapter); + + await wrapped.sendZdo("0xabcd", 0, 5 as never, {} as never); + + expect(emitSpy).toHaveBeenCalledWith("adapterSendZdo", expect.objectContaining({ieeeAddr: "0xabcd", clusterId: 5, status: "success"})); + }); + + it("emits adapterSendZdo on failure and re-throws", async () => { + const error = new Error("zdo failed"); + const adapter = makeMockAdapter({sendZdo: vi.fn().mockRejectedValue(error)}); + const wrapped = wrapWithMetrics(adapter); + + await expect(wrapped.sendZdo("0xabcd", 0, 5 as never, {} as never)).rejects.toThrow("zdo failed"); + expect(emitSpy).toHaveBeenCalledWith("adapterSendZdo", expect.objectContaining({ieeeAddr: "0xabcd", clusterId: 5, status: "failure"})); + }); + }); + + describe("sendZclFrameToGroup", () => { + it("emits adapterSendZclGroup on success", async () => { + const adapter = makeMockAdapter(); + const wrapped = wrapWithMetrics(adapter); + + await wrapped.sendZclFrameToGroup(7, {} as never); + + expect(emitSpy).toHaveBeenCalledWith("adapterSendZclGroup", expect.objectContaining({groupId: 7, status: "success"})); + }); + + it("emits adapterSendZclGroup on failure and re-throws", async () => { + const error = new Error("group failed"); + const adapter = makeMockAdapter({sendZclFrameToGroup: vi.fn().mockRejectedValue(error)}); + const wrapped = wrapWithMetrics(adapter); + + await expect(wrapped.sendZclFrameToGroup(7, {} as never)).rejects.toThrow("group failed"); + expect(emitSpy).toHaveBeenCalledWith("adapterSendZclGroup", expect.objectContaining({groupId: 7, status: "failure"})); + }); + }); + + describe("sendZclFrameToAll", () => { + it("emits adapterSendZclBroadcast on success", async () => { + const adapter = makeMockAdapter(); + const wrapped = wrapWithMetrics(adapter); + + await wrapped.sendZclFrameToAll(1, {} as never, 1, 260); + + expect(emitSpy).toHaveBeenCalledWith("adapterSendZclBroadcast", expect.objectContaining({status: "success"})); + }); + + it("emits adapterSendZclBroadcast on failure and re-throws", async () => { + const error = new Error("broadcast failed"); + const adapter = makeMockAdapter({sendZclFrameToAll: vi.fn().mockRejectedValue(error)}); + const wrapped = wrapWithMetrics(adapter); + + await expect(wrapped.sendZclFrameToAll(1, {} as never, 1, 260)).rejects.toThrow("broadcast failed"); + expect(emitSpy).toHaveBeenCalledWith("adapterSendZclBroadcast", expect.objectContaining({status: "failure"})); + }); + }); +}); diff --git a/test/controller.test.ts b/test/controller.test.ts index 24d81d54e2..f4677e6795 100755 --- a/test/controller.test.ts +++ b/test/controller.test.ts @@ -607,6 +607,15 @@ describe("Controller", () => { restoreMocksendZclFrameToEndpoint(); }); + it("wraps adapter with metrics when enableMetrics is true", async () => { + const newOptions = deepClone(options); + newOptions.enableMetrics = true; + const metricsController = new Controller(newOptions); + await metricsController.start(); + expect(mockAdapterStart).toHaveBeenCalled(); + await metricsController.stop(); + }); + it("Call controller constructor options mixed with default options", async () => { await controller.start(); expect(ZStackAdapter).toHaveBeenCalledWith( From 952f181e9960a4b9d332bab6b423fe772f770aec Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Tue, 28 Apr 2026 10:27:50 +0100 Subject: [PATCH 08/10] refactor: remove metricsAdapter wrapper and emit metrics directly in controller Replace the Proxy-based adapter wrapper and opt-in enableMetrics option with direct instrumentSend calls at each adapter send site in the controller, consistent with how receive/queue/retry metrics are already emitted. Signed-off-by: Tom Wilkie --- src/adapter/metricsAdapter.ts | 62 --------------- src/controller/controller.ts | 68 +++++++++++----- test/adapter/metricsAdapter.test.ts | 118 ---------------------------- test/controller.test.ts | 9 --- 4 files changed, 49 insertions(+), 208 deletions(-) delete mode 100644 src/adapter/metricsAdapter.ts delete mode 100644 test/adapter/metricsAdapter.test.ts diff --git a/src/adapter/metricsAdapter.ts b/src/adapter/metricsAdapter.ts deleted file mode 100644 index 229591de0c..0000000000 --- a/src/adapter/metricsAdapter.ts +++ /dev/null @@ -1,62 +0,0 @@ -import {metrics, type SendStatus} from "../utils/metrics.js"; -import type {Adapter} from "./adapter.js"; - -type AnyAsyncFn = (...args: unknown[]) => Promise; - -function instrumentSend(fn: AnyAsyncFn, record: (args: unknown[], status: SendStatus, durationSeconds: number) => void): AnyAsyncFn { - return async (...args) => { - const start = Date.now(); - try { - const result = await fn(...args); - record(args, "success", (Date.now() - start) / 1000); - return result; - } catch (e) { - record(args, "failure", (Date.now() - start) / 1000); - throw e; - } - }; -} - -// biome-ignore lint/suspicious/noExplicitAny: cast needed to bridge typed adapter methods to generic AnyAsyncFn -function asFn(fn: (...args: any[]) => Promise): AnyAsyncFn { - return fn as AnyAsyncFn; -} - -export function wrapWithMetrics(adapter: Adapter): Adapter { - const dispatch = new Map([ - [ - "sendZclFrameToEndpoint", - instrumentSend(asFn(adapter.sendZclFrameToEndpoint.bind(adapter)), ([ieeeAddr], status, durationSeconds) => - metrics.emit("adapterSendZclUnicast", {ieeeAddr: ieeeAddr as string, status, durationSeconds}), - ), - ], - [ - "sendZdo", - instrumentSend(asFn(adapter.sendZdo.bind(adapter)), ([ieeeAddr, , clusterId], status, durationSeconds) => - metrics.emit("adapterSendZdo", {ieeeAddr: ieeeAddr as string, clusterId: clusterId as number, status, durationSeconds}), - ), - ], - [ - "sendZclFrameToGroup", - instrumentSend(asFn(adapter.sendZclFrameToGroup.bind(adapter)), ([groupId], status, durationSeconds) => - metrics.emit("adapterSendZclGroup", {groupId: groupId as number, status, durationSeconds}), - ), - ], - [ - "sendZclFrameToAll", - instrumentSend(asFn(adapter.sendZclFrameToAll.bind(adapter)), (_args, status, durationSeconds) => - metrics.emit("adapterSendZclBroadcast", {status, durationSeconds}), - ), - ], - ]); - - return new Proxy(adapter, { - get(target, prop) { - if (typeof prop === "string" && dispatch.has(prop)) { - return dispatch.get(prop); - } - const value = Reflect.get(target, prop, target); - return typeof value === "function" ? (value as (...args: never) => unknown).bind(target) : value; - }, - }); -} diff --git a/src/controller/controller.ts b/src/controller/controller.ts index b47a01bc4d..ef45b749c7 100644 --- a/src/controller/controller.ts +++ b/src/controller/controller.ts @@ -3,10 +3,9 @@ import events from "node:events"; import fs from "node:fs"; import {Adapter, type Events as AdapterEvents, type TsType as AdapterTsType} from "../adapter"; import type {ZclPayload} from "../adapter/events"; -import {wrapWithMetrics} from "../adapter/metricsAdapter"; import {BackupUtils, wait} from "../utils"; import {logger} from "../utils/logger"; -import {metrics} from "../utils/metrics.js"; +import {metrics, type SendStatus} from "../utils/metrics.js"; import {isNumberArrayOfLength} from "../utils/utils"; import * as ZSpec from "../zspec"; import type {Eui64} from "../zspec/tstypes"; @@ -30,6 +29,18 @@ import type {DeviceType, GreenPowerDeviceJoinedPayload, RawPayload} from "./tsty const NS = "zh:controller"; +async function instrumentSend(fn: () => Promise, record: (status: SendStatus, durationSeconds: number) => void): Promise { + const start = Date.now(); + try { + const result = await fn(); + record("success", (Date.now() - start) / 1000); + return result; + } catch (e) { + record("failure", (Date.now() - start) / 1000); + throw e; + } +} + interface Options { network: AdapterTsType.NetworkOptions; serialPort: AdapterTsType.SerialPortOptions; @@ -37,7 +48,6 @@ interface Options { databaseBackupPath: string; backupPath: string; adapter: AdapterTsType.AdapterOptions; - enableMetrics?: boolean; /** * This lambda can be used by an application to explictly reject or accept an incoming device. * When false is returned zigbee-herdsman will not start the interview process and immidiately @@ -141,7 +151,7 @@ export class Controller extends events.EventEmitter { // Adapter (create and inject) const rawAdapter = await Adapter.create(this.options.network, this.options.serialPort, this.options.backupPath, this.options.adapter); - this.adapter = this.options.enableMetrics ? wrapWithMetrics(rawAdapter) : rawAdapter; + this.adapter = rawAdapter; abortSignal?.throwIfAborted(); @@ -284,7 +294,11 @@ export class Controller extends events.EventEmitter { // will fail if args are incorrect for request const buf = Zdo.Buffalo.buildRequest(this.adapter.hasZdoMessageOverhead, clusterKey, ...zdoParams); - return (await this.adapter.sendZdo(ieeeAddress, networkAddress, clusterKey, buf, disableResponse)) as ZdoTypes.GenericZdoResponse; + return (await instrumentSend( + () => this.adapter.sendZdo(ieeeAddress, networkAddress, clusterKey, buf, disableResponse), + (status, durationSeconds) => + metrics.emit("adapterSendZdo", {ieeeAddr: ieeeAddress, clusterId: clusterKey as number, status, durationSeconds}), + )) as ZdoTypes.GenericZdoResponse; } assert(zcl); @@ -334,7 +348,10 @@ export class Controller extends events.EventEmitter { if (groupId !== undefined) { assert(groupId >= 0x0000 && groupId <= 0xffff); - await this.adapter.sendZclFrameToGroup(groupId, zclFrame, srcEndpoint, profileId); + await instrumentSend( + () => this.adapter.sendZclFrameToGroup(groupId, zclFrame, srcEndpoint, profileId), + (status, durationSeconds) => metrics.emit("adapterSendZclGroup", {groupId, status, durationSeconds}), + ); return; } @@ -343,22 +360,29 @@ export class Controller extends events.EventEmitter { assert(networkAddress !== undefined && networkAddress >= 0x0000 && networkAddress <= 0xffff); if (networkAddress >= ZSpec.BROADCAST_MIN) { - await this.adapter.sendZclFrameToAll(dstEndpoint, zclFrame, srcEndpoint, networkAddress, profileId); + await instrumentSend( + () => this.adapter.sendZclFrameToAll(dstEndpoint, zclFrame, srcEndpoint, networkAddress, profileId), + (status, durationSeconds) => metrics.emit("adapterSendZclBroadcast", {status, durationSeconds}), + ); return; } assert(ieeeAddress); - return await this.adapter.sendZclFrameToEndpoint( - ieeeAddress, - networkAddress, - dstEndpoint, - zclFrame, - timeout, - disableResponse, - false, - srcEndpoint, - profileId, + return await instrumentSend( + () => + this.adapter.sendZclFrameToEndpoint( + ieeeAddress, + networkAddress, + dstEndpoint, + zclFrame, + timeout, + disableResponse, + false, + srcEndpoint, + profileId, + ), + (status, durationSeconds) => metrics.emit("adapterSendZclUnicast", {ieeeAddr: ieeeAddress, status, durationSeconds}), ); } @@ -627,7 +651,10 @@ export class Controller extends events.EventEmitter { undefined, ); - await this.adapter.sendZdo(ZSpec.BLANK_EUI64, ZSpec.BroadcastAddress.SLEEPY, clusterId, zdoPayload, true); + await instrumentSend( + () => this.adapter.sendZdo(ZSpec.BLANK_EUI64, ZSpec.BroadcastAddress.SLEEPY, clusterId, zdoPayload, true), + (status, durationSeconds) => metrics.emit("adapterSendZdo", {ieeeAddr: ZSpec.BLANK_EUI64, clusterId, status, durationSeconds}), + ); logger.info(`Channel changed to '${newChannel}'`, NS); this.networkParametersCached = undefined; // invalidate cache @@ -648,7 +675,10 @@ export class Controller extends events.EventEmitter { const zdoPayload = Zdo.Buffalo.buildRequest(this.adapter.hasZdoMessageOverhead, clusterId, nwkAddress, false, 0); try { - const response = await this.adapter.sendZdo(ZSpec.BLANK_EUI64, nwkAddress, clusterId, zdoPayload, false); + const response = await instrumentSend( + () => this.adapter.sendZdo(ZSpec.BLANK_EUI64, nwkAddress, clusterId, zdoPayload, false), + (status, durationSeconds) => metrics.emit("adapterSendZdo", {ieeeAddr: ZSpec.BLANK_EUI64, clusterId, status, durationSeconds}), + ); if (Zdo.Buffalo.checkStatus(response)) { const payload = response[1]; diff --git a/test/adapter/metricsAdapter.test.ts b/test/adapter/metricsAdapter.test.ts deleted file mode 100644 index 4dd15949a1..0000000000 --- a/test/adapter/metricsAdapter.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; -import type {Adapter} from "../../src/adapter/adapter.js"; -import {wrapWithMetrics} from "../../src/adapter/metricsAdapter.js"; -import {metrics} from "../../src/utils/metrics.js"; - -function makeMockAdapter(overrides: Partial unknown>> = {}): Adapter { - return { - sendZclFrameToEndpoint: vi.fn().mockResolvedValue(undefined), - sendZdo: vi.fn().mockResolvedValue(undefined), - sendZclFrameToGroup: vi.fn().mockResolvedValue(undefined), - sendZclFrameToAll: vi.fn().mockResolvedValue(undefined), - someOtherMethod: vi.fn().mockReturnValue(42), - someProperty: "prop-value", - ...overrides, - } as unknown as Adapter; -} - -describe("wrapWithMetrics", () => { - let emitSpy: ReturnType; - - beforeEach(() => { - emitSpy = vi.spyOn(metrics, "emit"); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("proxies non-dispatch methods and properties directly", () => { - const adapter = makeMockAdapter(); - const wrapped = wrapWithMetrics(adapter); - - // @ts-expect-error test property access - expect(wrapped.someProperty).toBe("prop-value"); - // @ts-expect-error test method access - expect(wrapped.someOtherMethod()).toBe(42); - }); - - describe("sendZclFrameToEndpoint", () => { - it("emits adapterSendZclUnicast on success", async () => { - const adapter = makeMockAdapter(); - const wrapped = wrapWithMetrics(adapter); - - await wrapped.sendZclFrameToEndpoint("0x1234", 1, 1, {} as never, 10000); - - expect(emitSpy).toHaveBeenCalledWith("adapterSendZclUnicast", expect.objectContaining({ieeeAddr: "0x1234", status: "success"})); - }); - - it("emits adapterSendZclUnicast on failure and re-throws", async () => { - const error = new Error("send failed"); - const adapter = makeMockAdapter({sendZclFrameToEndpoint: vi.fn().mockRejectedValue(error)}); - const wrapped = wrapWithMetrics(adapter); - - await expect(wrapped.sendZclFrameToEndpoint("0x1234", 1, 1, {} as never, 10000)).rejects.toThrow("send failed"); - expect(emitSpy).toHaveBeenCalledWith("adapterSendZclUnicast", expect.objectContaining({ieeeAddr: "0x1234", status: "failure"})); - }); - }); - - describe("sendZdo", () => { - it("emits adapterSendZdo on success", async () => { - const adapter = makeMockAdapter(); - const wrapped = wrapWithMetrics(adapter); - - await wrapped.sendZdo("0xabcd", 0, 5 as never, {} as never); - - expect(emitSpy).toHaveBeenCalledWith("adapterSendZdo", expect.objectContaining({ieeeAddr: "0xabcd", clusterId: 5, status: "success"})); - }); - - it("emits adapterSendZdo on failure and re-throws", async () => { - const error = new Error("zdo failed"); - const adapter = makeMockAdapter({sendZdo: vi.fn().mockRejectedValue(error)}); - const wrapped = wrapWithMetrics(adapter); - - await expect(wrapped.sendZdo("0xabcd", 0, 5 as never, {} as never)).rejects.toThrow("zdo failed"); - expect(emitSpy).toHaveBeenCalledWith("adapterSendZdo", expect.objectContaining({ieeeAddr: "0xabcd", clusterId: 5, status: "failure"})); - }); - }); - - describe("sendZclFrameToGroup", () => { - it("emits adapterSendZclGroup on success", async () => { - const adapter = makeMockAdapter(); - const wrapped = wrapWithMetrics(adapter); - - await wrapped.sendZclFrameToGroup(7, {} as never); - - expect(emitSpy).toHaveBeenCalledWith("adapterSendZclGroup", expect.objectContaining({groupId: 7, status: "success"})); - }); - - it("emits adapterSendZclGroup on failure and re-throws", async () => { - const error = new Error("group failed"); - const adapter = makeMockAdapter({sendZclFrameToGroup: vi.fn().mockRejectedValue(error)}); - const wrapped = wrapWithMetrics(adapter); - - await expect(wrapped.sendZclFrameToGroup(7, {} as never)).rejects.toThrow("group failed"); - expect(emitSpy).toHaveBeenCalledWith("adapterSendZclGroup", expect.objectContaining({groupId: 7, status: "failure"})); - }); - }); - - describe("sendZclFrameToAll", () => { - it("emits adapterSendZclBroadcast on success", async () => { - const adapter = makeMockAdapter(); - const wrapped = wrapWithMetrics(adapter); - - await wrapped.sendZclFrameToAll(1, {} as never, 1, 260); - - expect(emitSpy).toHaveBeenCalledWith("adapterSendZclBroadcast", expect.objectContaining({status: "success"})); - }); - - it("emits adapterSendZclBroadcast on failure and re-throws", async () => { - const error = new Error("broadcast failed"); - const adapter = makeMockAdapter({sendZclFrameToAll: vi.fn().mockRejectedValue(error)}); - const wrapped = wrapWithMetrics(adapter); - - await expect(wrapped.sendZclFrameToAll(1, {} as never, 1, 260)).rejects.toThrow("broadcast failed"); - expect(emitSpy).toHaveBeenCalledWith("adapterSendZclBroadcast", expect.objectContaining({status: "failure"})); - }); - }); -}); diff --git a/test/controller.test.ts b/test/controller.test.ts index f4677e6795..24d81d54e2 100755 --- a/test/controller.test.ts +++ b/test/controller.test.ts @@ -607,15 +607,6 @@ describe("Controller", () => { restoreMocksendZclFrameToEndpoint(); }); - it("wraps adapter with metrics when enableMetrics is true", async () => { - const newOptions = deepClone(options); - newOptions.enableMetrics = true; - const metricsController = new Controller(newOptions); - await metricsController.start(); - expect(mockAdapterStart).toHaveBeenCalled(); - await metricsController.stop(); - }); - it("Call controller constructor options mixed with default options", async () => { await controller.start(); expect(ZStackAdapter).toHaveBeenCalledWith( From 4c3635e885bd8aa608aa0486aa2c7193afff9df1 Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Tue, 28 Apr 2026 10:33:25 +0100 Subject: [PATCH 09/10] Remove unnecessary changes. Signed-off-by: Tom Wilkie --- src/controller/controller.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/controller/controller.ts b/src/controller/controller.ts index ef45b749c7..4087a524f4 100644 --- a/src/controller/controller.ts +++ b/src/controller/controller.ts @@ -150,8 +150,7 @@ export class Controller extends events.EventEmitter { Entity.injectDatabase(this.database); // Adapter (create and inject) - const rawAdapter = await Adapter.create(this.options.network, this.options.serialPort, this.options.backupPath, this.options.adapter); - this.adapter = rawAdapter; + this.adapter = await Adapter.create(this.options.network, this.options.serialPort, this.options.backupPath, this.options.adapter); abortSignal?.throwIfAborted(); From ee1dc066c3321c63a5031524954ee53f55f29f63 Mon Sep 17 00:00:00 2001 From: Tom Wilkie Date: Tue, 28 Apr 2026 10:36:49 +0100 Subject: [PATCH 10/10] Remove incorrect retry metric emit. Signed-off-by: Tom Wilkie --- src/adapter/ember/adapter/emberAdapter.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/adapter/ember/adapter/emberAdapter.ts b/src/adapter/ember/adapter/emberAdapter.ts index 135b0825f0..c0966d15c0 100644 --- a/src/adapter/ember/adapter/emberAdapter.ts +++ b/src/adapter/ember/adapter/emberAdapter.ts @@ -2012,7 +2012,6 @@ export class EmberAdapter extends Adapter { metrics.emit("adapterRetry", {adapterType: "ember", ieeeAddr, reason: SLStatus[status] ?? String(status)}); await wait(QUEUE_BUSY_DEFER_MSEC); } else if (status === SLStatus.NETWORK_DOWN) { - metrics.emit("adapterRetry", {adapterType: "ember", ieeeAddr, reason: "NETWORK_DOWN"}); await wait(QUEUE_NETWORK_DOWN_DEFER_MSEC); } else { throw new Error(