diff --git a/packages/cli/src/commands/metrics.ts b/packages/cli/src/commands/metrics.ts index f4d2a8b..69cf920 100644 --- a/packages/cli/src/commands/metrics.ts +++ b/packages/cli/src/commands/metrics.ts @@ -98,6 +98,10 @@ function activeChannels(channels: DeviceChannel[]): DeviceChannel[] { return channels.filter((ch) => ch.enabled !== false); } +function ageSeconds(timestamp: Date): number { + return Math.max(0, Math.floor((Date.now() - timestamp.getTime()) / 1000)); +} + function channelLabels(device: Device, channel: DeviceChannel): Record { return { serial: device.serial, @@ -119,8 +123,18 @@ export function renderMetrics(snapshot: MetricsSnapshot): string { const alarmHigh: Sample[] = []; const alarmLow: Sample[] = []; const battery: Sample[] = []; + const deviceTelemetryAge: Sample[] = []; + const channelTelemetryAge: Sample[] = []; for (const { device, channels } of snapshot.devices) { + const deviceTimestamp = device.latestReading ?? device.lastTelemetrySaved ?? device.lastSeen; + if (deviceTimestamp) { + deviceTelemetryAge.push({ + labels: { serial: device.serial, device: device.label || device.serial }, + value: ageSeconds(deviceTimestamp), + }); + } + if (device.battery != null) { battery.push({ labels: { serial: device.serial, device: device.label || device.serial }, @@ -133,6 +147,10 @@ export function renderMetrics(snapshot: MetricsSnapshot): string { if (channel.value != null) { temperature.push({ labels, value: channel.value }); } + const channelTimestamp = channel.lastTelemetrySaved ?? channel.lastSeen; + if (channelTimestamp) { + channelTelemetryAge.push({ labels, value: ageSeconds(channelTimestamp) }); + } if (channel.minimum?.value != null) { minimum.push({ labels, value: channel.minimum.value }); } @@ -197,6 +215,18 @@ export function renderMetrics(snapshot: MetricsSnapshot): string { type: "gauge", samples: battery, }, + { + name: "thermoworks_device_telemetry_age_seconds", + help: "Seconds since the newest device telemetry timestamp.", + type: "gauge", + samples: deviceTelemetryAge, + }, + { + name: "thermoworks_channel_telemetry_age_seconds", + help: "Seconds since the newest channel telemetry timestamp.", + type: "gauge", + samples: channelTelemetryAge, + }, ]; const lines = metrics.flatMap(renderMetric); diff --git a/packages/cli/tests/metrics.test.ts b/packages/cli/tests/metrics.test.ts index 612ec80..afe30c1 100644 --- a/packages/cli/tests/metrics.test.ts +++ b/packages/cli/tests/metrics.test.ts @@ -37,9 +37,9 @@ function makeDevice(overrides: Partial & { serial: string }): Device { temperatureDeltaTrigger: null, pendingLoad: null, batteryAlertSent: null, - lastSeen: null, - lastTelemetrySaved: null, - latestReading: null, + lastSeen: overrides.lastSeen ?? null, + lastTelemetrySaved: overrides.lastTelemetrySaved ?? null, + latestReading: overrides.latestReading ?? null, lastWifiConnection: null, lastBluetoothConnection: null, sessionStart: null, @@ -70,8 +70,8 @@ function makeChannel(overrides: Partial = {}): DeviceChannel { number: overrides.number ?? null, enabled: overrides.enabled ?? null, color: null, - lastSeen: null, - lastTelemetrySaved: null, + lastSeen: overrides.lastSeen ?? null, + lastTelemetrySaved: overrides.lastTelemetrySaved ?? null, lastEventId: null, showAvgTemp: null, estimatedAlarmStatus: null, @@ -296,6 +296,54 @@ describe("renderMetrics", () => { }); }); +it("emits telemetry age metrics when timestamps are present", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T12:10:00Z")); + try { + const devices: DeviceWithChannels[] = [ + { + device: makeDevice({ + serial: "S1", + label: "Smoker", + latestReading: new Date("2026-01-15T12:08:00Z"), + }), + channels: [ + makeChannel({ + label: "Pit", + number: "1", + lastTelemetrySaved: new Date("2026-01-15T12:09:30Z"), + }), + ], + }, + ]; + + const text = renderMetrics({ up: true, scrapeErrors: 0, devices }); + + expect(text).toContain( + 'thermoworks_device_telemetry_age_seconds{serial="S1",device="Smoker"} 120', + ); + expect(text).toContain( + 'thermoworks_channel_telemetry_age_seconds{serial="S1",device="Smoker",channel="1",label="Pit"} 30', + ); + } finally { + vi.useRealTimers(); + } +}); + +it("skips telemetry age metrics when timestamps are missing", () => { + const devices: DeviceWithChannels[] = [ + { + device: makeDevice({ serial: "S1", label: "Smoker" }), + channels: [makeChannel({ label: "Pit", number: "1" })], + }, + ]; + + const text = renderMetrics({ up: true, scrapeErrors: 0, devices }); + + expect(text).not.toContain("thermoworks_device_telemetry_age_seconds{"); + expect(text).not.toContain("thermoworks_channel_telemetry_age_seconds{"); +}); + // ============================================================================= // createMetricsServer (real HTTP) // =============================================================================