Eu descrevi o uso do sensor temper com raspberrypi no artigo monitorando a temperatura da sala dos servidores. O que eu não contei ali foi que eu passei a monitorar outros parâmetros mostrado pelo programa sensor. O sensor faz parte do pacote lm-sensor no Ubuntu.

Rodando o programa com parâmetro -j mostra a saída em format JSON.

  
❯ sensors -j
{
   "coretemp-isa-0000":{
      "Adapter": "ISA adapter",
      "Package id 0":{
         "temp1_input": 61.000,
         "temp1_max": 100.000,
         "temp1_crit": 100.000,
         "temp1_crit_alarm": 0.000
      },
      "Core 0":{
         "temp2_input": 52.000,
         "temp2_max": 100.000,
         "temp2_crit": 100.000,
         "temp2_crit_alarm": 0.000
      },
      "Core 4":{
         "temp6_input": 54.000,
         "temp6_max": 100.000,
         "temp6_crit": 100.000,
         "temp6_crit_alarm": 0.000
      },
      "Core 8":{
         "temp10_input": 61.000,
         "temp10_max": 100.000,
         "temp10_crit": 100.000,
         "temp10_crit_alarm": 0.000
      },
      "Core 9":{
         "temp11_input": 61.000,
         "temp11_max": 100.000,
         "temp11_crit": 100.000,
         "temp11_crit_alarm": 0.000
      },
      "Core 10":{
         "temp12_input": 61.000,
         "temp12_max": 100.000,
         "temp12_crit": 100.000,
         "temp12_crit_alarm": 0.000
      },
      "Core 11":{
         "temp13_input": 61.000,
         "temp13_max": 100.000,
         "temp13_crit": 100.000,
         "temp13_crit_alarm": 0.000
      },
      "Core 12":{
         "temp14_input": 59.000,
         "temp14_max": 100.000,
         "temp14_crit": 100.000,
         "temp14_crit_alarm": 0.000
      },
      "Core 13":{
         "temp15_input": 59.000,
         "temp15_max": 100.000,
         "temp15_crit": 100.000,
         "temp15_crit_alarm": 0.000
      },
      "Core 14":{
         "temp16_input": 59.000,
         "temp16_max": 100.000,
         "temp16_crit": 100.000,
         "temp16_crit_alarm": 0.000
      },
      "Core 15":{
         "temp17_input": 60.000,
         "temp17_max": 100.000,
         "temp17_crit": 100.000,
         "temp17_crit_alarm": 0.000
      }
   },
   "thinkpad-isa-0000":{
      "Adapter": "ISA adapter",
      "fan1":{
         "fan1_input": 2191.000
      },
      "CPU":{
         "temp1_input": 59.000
      },
      "GPU":{
ERROR: Can't get value of subfeature temp2_input: Can't read

      },
      "temp3":{
         "temp3_input": 59.000
      },
      "temp4":{
         "temp4_input": 0.000
      },
      "temp5":{
         "temp5_input": 59.000
      },
      "temp6":{
         "temp6_input": 59.000
      },
      "temp7":{
         "temp7_input": 59.000
      },
      "temp8":{
ERROR: Can't get value of subfeature temp8_input: Can't read

      }
   },
   "ucsi_source_psy_USBC000:001-isa-0000":{
      "Adapter": "ISA adapter",
      "in0":{
         "in0_input": 0.000,
         "in0_min": 0.000,
         "in0_max": 0.000
      },
      "curr1":{
         "curr1_input": 0.000,
         "curr1_max": 0.000
      }
   },
   "BAT0-acpi-0":{
      "Adapter": "ACPI interface",
      "in0":{
         "in0_input": 12.909
      },
      "power1":{
         "power1_input": 0.000
      }
   },
   "iwlwifi_1-virtual-0":{
      "Adapter": "Virtual device",
      "temp1":{
         "temp1_input": 42.000
      }
   },
   "ucsi_source_psy_USBC000:002-isa-0000":{
      "Adapter": "ISA adapter",
      "in0":{
         "in0_input": 0.000,
         "in0_min": 0.000,
         "in0_max": 0.000
      },
      "curr1":{
         "curr1_input": 3.000,
         "curr1_max": 0.000
      }
   },
   "nvme-pci-0200":{
      "Adapter": "PCI adapter",
      "Composite":{
         "temp1_input": 44.850,
         "temp1_max": 85.850,
         "temp1_min": -273.150,
         "temp1_crit": 86.850,
         "temp1_alarm": 0.000
      },
      "Sensor 1":{
         "temp2_input": 47.850,
         "temp2_max": 65261.850,
         "temp2_min": -273.150
      },
      "Sensor 2":{
         "temp3_input": 44.850,
         "temp3_max": 65261.850,
         "temp3_min": -273.150
      }
   },
   "acpitz-acpi-0":{
      "Adapter": "ACPI interface",
      "temp1":{
         "temp1_input": 59.000
      }
   }
}    
  

Essa saída de comando é do laptop de trabalho, onde estou escrevendo esse artigo. É possível ver que aparecem alguns erros como ERROR: Can't get value of subfeature temp2_input: Can't read e que alguns dados não tem valor como em { "coretemp-isa-0000":{ "Adapter": "ISA adapter" } }.

Quando fiz pro servidor, eu acabei meio que escrevendo o código na mão.

  
    sensors = shellExec("/usr/bin/sensors -j")
    logger.debug(f"sensors: {sensors}")
    jResp = json.loads(sensors)

    resp = list()
    resp.append("#HELP server_board_temperature_celsius the server board current temperatures")
    resp.append("#TYPE server_board_temperature_celsius gauge")
    device_1 = "nvme-pci-2b00"
    device_2 = "k10temp-pci-00c3"
    composite = jResp[device_1]["Composite"]["temp1_input"]
    logger.info(f"device: {device_1}, composite, temperature: {composite}")
    resp.append("server_board_temperature_celsius{device=\"" + device_1 + "\",sensor=\"composite\"} " + "%0.2f" % composite )
    sensor_1 = jResp[device_1]["Sensor 1"]["temp2_input"]
    resp.append("server_board_temperature_celsius{device=\"" + device_1 + "\",sensor=\"sensor_1\"} " + "%0.2f" % sensor_1 )
    sensor_2 = jResp[device_1]["Sensor 2"]["temp3_input"]
    resp.append("server_board_temperature_celsius{device=\"" + device_1 + "\",sensor=\"sensor_2\"} " + "%0.2f" % sensor_2 )
    tctl = jResp[device_2]["Tctl"]["temp1_input"]
    resp.append("server_board_temperature_celsius{device=\"" + device_2 + "\",sensor=\"tctl\"} " + "%0.2f" % tctl )
    tccd3 = jResp[device_2]["Tccd3"]["temp5_input"]
    resp.append("server_board_temperature_celsius{device=\"" + device_2 + "\",sensor=\"tccd3\"} " + "%0.2f" % tccd3 )
    tccd5 = jResp[device_2]["Tccd5"]["temp7_input"]
    resp.append("server_board_temperature_celsius{device=\"" + device_2 + "\",sensor=\"tccd5\"} " + "%0.2f" % tccd5 )
    resp.append("")    
  

Então essa semana eu gastei um tempo pra fazer um script mais genérico. Aparecem agora todos os dados que saem com valor no comando sensor -j, mas em contrapartida não sei exatamente o que são.

Sem mais delongas, aqui o código:

  
#! /usr/bin/env -S uv run --script
#
# /// script
# dependencies = [
#    "uvicorn",
#    "fastapi"
# ]
# ///

import argparse
import sys
import subprocess
import logging
import json
import re

from fastapi import FastAPI
from fastapi.responses import PlainTextResponse
import uvicorn


DEFAULTS = {"port": 9090, "path": "/metrics"}
__version__ = "0.1.0"

logger = logging.getLogger(__file__)
consoleOutputHandler = logging.StreamHandler()
formatter = logging.Formatter(
    fmt="[%(asctime)s] (%(levelname)s) %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
)
consoleOutputHandler.setFormatter(formatter)
logger.addHandler(consoleOutputHandler)
logger.setLevel(logging.INFO)


def shellExec(command: str) -> str:
    "run a command and return its output"
    try:
        # result = subprocess.getoutput(command, errors=subprocess.DEVNULL)
        result = subprocess.check_output(
            command, stderr=subprocess.DEVNULL, encoding="utf-8"
        )
        logger.debug(f"shellExec: {result}")
        return result
    except Exception as e:
        logger.error(f"Error executing shell command '{command}': {e}")
        return f"Error: {e}"


class SensorMetrics:
    open_metrics: dict = {}

    def generateMetrics(self) -> list:
        self.dataReset()
        sensors_output = shellExec(["sensors", "-j"])
        logger.debug(f"sensors: {sensors_output}")
        sensorsJSON = json.loads(sensors_output)
        # sensorsJSON = {
        #     "thinkpad-isa-0000": {
        #         "Adapter": "ISA adapter",
        #         "fan1": {"fan1_input": 2786.000},
        #         "CPU": {"temp1_input": 69.000},
        #         "GPU": {},
        #         "temp3": {"temp3_input": 69.000},
        #         "temp4": {"temp4_input": 0.000},
        #         "temp5": {"temp5_input": 69.000},
        #         "temp6": {"temp6_input": 69.000},
        #         "temp7": {"temp7_input": 69.000},
        #         "temp8": {},
        #     }
        # }
        self.getOpenMetrics([], sensorsJSON)

        return []

    def dataReset(self):
        self.open_metrics = {}

    def getOpenMetrics(self, header: list, data_dict: dict):
        if isinstance(data_dict, dict):
            for k, v in data_dict.items():
                logger.debug(f"k={k}, v={v}")
                if isinstance(v, dict):
                    self.getOpenMetrics(header + [k], v)
                else:
                    logger.debug(
                        f"Value is not dictionary: header={header}, k={k}, v={v}"
                    )
                    try:
                        v = float(v)
                    except ValueError:
                        logger.debug(f"invalid value: {v}")
                        continue
                    metric_head = self.generateMetricHeader(header, k)
                    metric_description = " ".join(header)
                    metric_description += f" {k}"

                    if metric_head in self.open_metrics:
                        logger.error(f"metric name '{metric_head}' already exists")

                    self.open_metrics[metric_head] = {
                        "value": v,
                        "description": metric_description,
                    }
                    logger.debug(
                        f"Adding: metric_head={metric_head}, value={v}, description='{metric_description}'"
                    )
        else:
            logger.debug(f"Not dictionary: header={header}, data_dict={data_dict}")

    def generateMetricHeader(self, header: list, key: str) -> str:
        metric_header = "_".join(header)
        metric_header = re.sub(" ", "_", metric_header)
        return f"{metric_header}_{key}"


if __name__ == "__main__":
    parse = argparse.ArgumentParser(
        description="Script to expose the sensors as open metrics"
    )
    parse.add_argument(
        "--port", type=int, default=DEFAULTS["port"], help="Port to listen"
    )
    parse.add_argument(
        "--path", default=DEFAULTS["path"], help="The path to serve the metrics"
    )

    parse.add_argument(
        "--version",
        action=argparse.BooleanOptionalAction,
        help="Print version and exit",
    )
    parse.add_argument(
        "--printout",
        action=argparse.BooleanOptionalAction,
        help="Print the exposed metrics found in the system",
    )

    parse.add_argument("--loglevel", default="info", help="Logging level")

    args = parse.parse_args()

    if args.loglevel != "info":
        logger.setLevel(args.loglevel.upper())

    if args.version is True:
        print(sys.argv[0], __version__)
        sys.exit(0)

    if args.printout is True:
        mts = SensorMetrics()
        mts.generateMetrics()
        for k, v in mts.open_metrics.items():
            description = v["description"]
            print(f"#HELP {k} {description}")
            print(f"#TYPE {k} gauge")
            print(f"{k}: {v['value']}")
        sys.exit(0)

    app = FastAPI()

    @app.get(args.path, response_class=PlainTextResponse)
    async def metrics():
        logger.info(f"serving web page on {args.path}")
        mts = SensorMetrics()
        mts.generateMetrics()
        resp = list()
        for k, v in mts.open_metrics.items():
            description = v["description"]
            resp.append(f"#HELP {k} {description}")
            resp.append(f"#TYPE {k} gauge")
            resp.append(f"{k}: {v['value']}")
        return "\n".join(resp)

    logger.info(f"starting service on port {args.port}")
    uvicorn.run(app, host="127.0.0.1", port=args.port)    
  

O código pode ser encontrado aqui:

Olhando o código mais de perto

Como pode ser visto no cabeçalho:

  
#! /usr/bin/env -S uv run --script
#
# /// script
# dependencies = [
#    "uvicorn",
#    "fastapi"
# ]
# ///    
  

abracei com força a dica do querido Riverfount, que mostrou que o uv podia fazer isso.

E passei a adotar o pacote logging ao invés de enviar prints pro terminal.

  
logger = logging.getLogger(__file__)
consoleOutputHandler = logging.StreamHandler()
formatter = logging.Formatter(
    fmt="[%(asctime)s] (%(levelname)s) %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
)
consoleOutputHandler.setFormatter(formatter)
logger.addHandler(consoleOutputHandler)
logger.setLevel(logging.INFO)    
  

Eu sempre amarro esse singleton de logging, logger, com função do argparse pra poder modificar o nível. Começo sempre com info, mas usando o parâmetro --loglevel=debug eu mudo pro nível debug. Torna prático e flexível pra gerenciar logs de funcionamento. Além disso o logging usa STDERR pra enviar as mensagens.

Olhando agora pra parte de execução do script shell, onde sensors -j é chamado:

  
def shellExec(command: str) -> str:
    "run a command and return its output"
    try:
        # result = subprocess.getoutput(command, errors=subprocess.DEVNULL)
        result = subprocess.check_output(
            command, stderr=subprocess.DEVNULL, encoding="utf-8"
        )
        logger.debug(f"shellExec: {result}")
        return result
    except Exception as e:
        logger.error(f"Error executing shell command '{command}': {e}")
        return f"Error: {e}"    
  

Uso como função ao invés de método da classe. Acho que fica mais simples. E poder rodar script do shell dentro de uma classe não parece lá muito como uma propriedade da classe em si.

Notem que inicialmente eu tentei usar subprocess.getoutput( ). O problema são aquelas mensagens de erros que eu comentei acima, ERROR: Can't get value of subfeature temp2_input: Can't read, que saem junto. Isso quebra a chamada do json.loads( ).

Tentei usar o parâmetro errors=subprocess.DEVNULL, mas aparentemente o subprocess.getoutput( ) não herda isso. Então não funciona. O jeito foi usar subprocess.check_output( ). A grande diferença é que o primeiro aceita string, então poderia usar simplemente "sensors -j" como argumento. No subprocess.check_output( ), o parâmetro precisa ser uma lista. Tem de ser passado como ["sensors", "-j"] que não é o fim do mundo, só um pouco mais chato.

A classe SensorMetrics está bem simples de entender no começo. Acho que a única parte que precisa de um pouco mais de cuidado pra olhar é o método getOpenMetrics( ).

  
    def getOpenMetrics(self, header: list, data_dict: dict):
        if isinstance(data_dict, dict):
            for k, v in data_dict.items():
                logger.debug(f"k={k}, v={v}")
                if isinstance(v, dict):
                    self.getOpenMetrics(header + [k], v)
                else:
                    logger.debug(
                        f"Value is not dictionary: header={header}, k={k}, v={v}"
                    )
                    try:
                        v = float(v)
                    except ValueError:
                        logger.debug(f"invalid value: {v}")
                        continue
                    metric_head = self.generateMetricHeader(header, k)
                    metric_description = " ".join(header)
                    metric_description += f" {k}"

                    if metric_head in self.open_metrics:
                        logger.error(f"metric name '{metric_head}' already exists")

                    self.open_metrics[metric_head] = {
                        "value": v,
                        "description": metric_description,
                    }
                    logger.debug(
                        f"Adding: metric_head={metric_head}, value={v}, description='{metric_description}'"
                    )
        else:
            logger.debug(f"Not dictionary: header={header}, data_dict={data_dict}")
  

Esse é um método recursivo. Olhando pra uma estrutura como essa aqui:

  
{
   "coretemp-isa-0000":{
      "Package id 0":{
         "temp1_input": 61.000,
         "temp1_max": 100.000,
         "temp1_crit": 100.000,
         "temp1_crit_alarm": 0.000
      }
    }
}
  

O loop quebra o dicionário recebido em k, v, ou "key" e "value". Na primeira vez que recebe, os dados ficam assim:

  • key: "coretemp-isa-0000"
  • value: {"Package id 0":{"temp1_input": 61.000,"temp1_max": 100.000,"temp1_crit": 100.000,"temp1_crit_alarm": 0.000 }}

Então ele se chama de novo, mas passa no parâmetro header a primeira chave que encontrou: coretemp-isa-0000.

  • key: "Package id 0"
  • value: {"temp1_input": 61.000,"temp1_max": 100.000,"temp1_max": 100.000,"temp1_crit_alarm": 0.000 }

Como value ainda é um dictionário, testando com isinstance( ), então ele adiciona a chave Package id 0 na lista de headers e roda mais uma vez:

  • key=temp1_input, value=61.000
  • key=temp1_max, value=100.000
  • key=temp1_max, value=100.000
  • key=temp1_crit_alarm, value=0.000

Como é um for-loop, ele pega essas chaves e valores. Daí como tem o histórico do que veio antes dentro de header, fica fácil montar a estrutura. Basta juntar tudo com "_" como separador.

  
    def generateMetricHeader(self, header: list, key: str) -> str:
        metric_header = "_".join(header)
        metric_header = re.sub(" ", "_", metric_header)
        return f"{metric_header}_{key}"  
  

E é isso que é feito no método generateMetricHeader( ).

Espero que agora tenha ficado um pouco mais claro o que foi que eu fiz nesse programa. E o porquê de não saber se o valor é temperatura em Celsius ou o que seja, uma vez que esse dado não existe na estrutura em JSON gerada. Ou se é tensão em Volts. Fica tudo no mesmo balaio.

Nota: a manpage do sensors descreve que toda temperatura é por padrão em °C pra temperatura. E também descreve que eu deveria estar usando o parâmetro "-J", que sai no formato que preciso. Isso fica pra versão 0.2.0 do script 😊.

Executando o programa

Rodando simplemente pelo shell, temos a seguinte saída (que pode mudar de acordo com seu hardware):

  
❯ sensors
coretemp-isa-0000
Adapter: ISA adapter
Package id 0:  +61.0°C  (high = +100.0°C, crit = +100.0°C)
Core 0:        +53.0°C  (high = +100.0°C, crit = +100.0°C)
Core 4:        +57.0°C  (high = +100.0°C, crit = +100.0°C)
Core 8:        +61.0°C  (high = +100.0°C, crit = +100.0°C)
Core 9:        +61.0°C  (high = +100.0°C, crit = +100.0°C)
Core 10:       +61.0°C  (high = +100.0°C, crit = +100.0°C)
Core 11:       +61.0°C  (high = +100.0°C, crit = +100.0°C)
Core 12:       +60.0°C  (high = +100.0°C, crit = +100.0°C)
Core 13:       +60.0°C  (high = +100.0°C, crit = +100.0°C)
Core 14:       +60.0°C  (high = +100.0°C, crit = +100.0°C)
Core 15:       +60.0°C  (high = +100.0°C, crit = +100.0°C)

thinkpad-isa-0000
Adapter: ISA adapter
fan1:        2199 RPM
CPU:          +60.0°C  
GPU:              N/A  
temp3:        +60.0°C  
temp4:         +0.0°C  
temp5:        +60.0°C  
temp6:        +60.0°C  
temp7:        +60.0°C  
temp8:            N/A  

ucsi_source_psy_USBC000:001-isa-0000
Adapter: ISA adapter
in0:           0.00 V  (min =  +0.00 V, max =  +0.00 V)
curr1:         0.00 A  (max =  +0.00 A)

BAT0-acpi-0
Adapter: ACPI interface
in0:          12.91 V  
power1:        0.00 W  

iwlwifi_1-virtual-0
Adapter: Virtual device
temp1:        +43.0°C  

ucsi_source_psy_USBC000:002-isa-0000
Adapter: ISA adapter
in0:           0.00 V  (min =  +0.00 V, max =  +0.00 V)
curr1:         3.00 A  (max =  +0.00 A)

nvme-pci-0200
Adapter: PCI adapter
Composite:    +45.9°C  (low  = -273.1°C, high = +85.8°C)
                       (crit = +86.8°C)
Sensor 1:     +48.9°C  (low  = -273.1°C, high = +65261.8°C)
Sensor 2:     +45.9°C  (low  = -273.1°C, high = +65261.8°C)

acpitz-acpi-0
Adapter: ACPI interface
temp1:        +60.0°C  

❯ ./sensors-open-metrics.py --printout
#HELP coretemp-isa-0000_Package_id_0_temp1_input coretemp-isa-0000 Package id 0 temp1_input
#TYPE coretemp-isa-0000_Package_id_0_temp1_input gauge
coretemp-isa-0000_Package_id_0_temp1_input: 66.0
#HELP coretemp-isa-0000_Package_id_0_temp1_max coretemp-isa-0000 Package id 0 temp1_max
#TYPE coretemp-isa-0000_Package_id_0_temp1_max gauge
coretemp-isa-0000_Package_id_0_temp1_max: 100.0
#HELP coretemp-isa-0000_Package_id_0_temp1_crit coretemp-isa-0000 Package id 0 temp1_crit
#TYPE coretemp-isa-0000_Package_id_0_temp1_crit gauge
coretemp-isa-0000_Package_id_0_temp1_crit: 100.0
#HELP coretemp-isa-0000_Package_id_0_temp1_crit_alarm coretemp-isa-0000 Package id 0 temp1_crit_alarm
#TYPE coretemp-isa-0000_Package_id_0_temp1_crit_alarm gauge
coretemp-isa-0000_Package_id_0_temp1_crit_alarm: 0.0
#HELP coretemp-isa-0000_Core_0_temp2_input coretemp-isa-0000 Core 0 temp2_input
#TYPE coretemp-isa-0000_Core_0_temp2_input gauge
coretemp-isa-0000_Core_0_temp2_input: 61.0
#HELP coretemp-isa-0000_Core_0_temp2_max coretemp-isa-0000 Core 0 temp2_max
#TYPE coretemp-isa-0000_Core_0_temp2_max gauge
coretemp-isa-0000_Core_0_temp2_max: 100.0
#HELP coretemp-isa-0000_Core_0_temp2_crit coretemp-isa-0000 Core 0 temp2_crit
#TYPE coretemp-isa-0000_Core_0_temp2_crit gauge
coretemp-isa-0000_Core_0_temp2_crit: 100.0
#HELP coretemp-isa-0000_Core_0_temp2_crit_alarm coretemp-isa-0000 Core 0 temp2_crit_alarm
#TYPE coretemp-isa-0000_Core_0_temp2_crit_alarm gauge
coretemp-isa-0000_Core_0_temp2_crit_alarm: 0.0
#HELP coretemp-isa-0000_Core_4_temp6_input coretemp-isa-0000 Core 4 temp6_input
#TYPE coretemp-isa-0000_Core_4_temp6_input gauge
coretemp-isa-0000_Core_4_temp6_input: 58.0
#HELP coretemp-isa-0000_Core_4_temp6_max coretemp-isa-0000 Core 4 temp6_max
#TYPE coretemp-isa-0000_Core_4_temp6_max gauge
coretemp-isa-0000_Core_4_temp6_max: 100.0
#HELP coretemp-isa-0000_Core_4_temp6_crit coretemp-isa-0000 Core 4 temp6_crit
#TYPE coretemp-isa-0000_Core_4_temp6_crit gauge
coretemp-isa-0000_Core_4_temp6_crit: 100.0
#HELP coretemp-isa-0000_Core_4_temp6_crit_alarm coretemp-isa-0000 Core 4 temp6_crit_alarm
#TYPE coretemp-isa-0000_Core_4_temp6_crit_alarm gauge
coretemp-isa-0000_Core_4_temp6_crit_alarm: 0.0
#HELP coretemp-isa-0000_Core_8_temp10_input coretemp-isa-0000 Core 8 temp10_input
#TYPE coretemp-isa-0000_Core_8_temp10_input gauge
coretemp-isa-0000_Core_8_temp10_input: 66.0
#HELP coretemp-isa-0000_Core_8_temp10_max coretemp-isa-0000 Core 8 temp10_max
#TYPE coretemp-isa-0000_Core_8_temp10_max gauge
coretemp-isa-0000_Core_8_temp10_max: 100.0
#HELP coretemp-isa-0000_Core_8_temp10_crit coretemp-isa-0000 Core 8 temp10_crit
#TYPE coretemp-isa-0000_Core_8_temp10_crit gauge
coretemp-isa-0000_Core_8_temp10_crit: 100.0
#HELP coretemp-isa-0000_Core_8_temp10_crit_alarm coretemp-isa-0000 Core 8 temp10_crit_alarm
#TYPE coretemp-isa-0000_Core_8_temp10_crit_alarm gauge
coretemp-isa-0000_Core_8_temp10_crit_alarm: 0.0
#HELP coretemp-isa-0000_Core_9_temp11_input coretemp-isa-0000 Core 9 temp11_input
#TYPE coretemp-isa-0000_Core_9_temp11_input gauge
coretemp-isa-0000_Core_9_temp11_input: 66.0
#HELP coretemp-isa-0000_Core_9_temp11_max coretemp-isa-0000 Core 9 temp11_max
#TYPE coretemp-isa-0000_Core_9_temp11_max gauge
coretemp-isa-0000_Core_9_temp11_max: 100.0
#HELP coretemp-isa-0000_Core_9_temp11_crit coretemp-isa-0000 Core 9 temp11_crit
#TYPE coretemp-isa-0000_Core_9_temp11_crit gauge
coretemp-isa-0000_Core_9_temp11_crit: 100.0
#HELP coretemp-isa-0000_Core_9_temp11_crit_alarm coretemp-isa-0000 Core 9 temp11_crit_alarm
#TYPE coretemp-isa-0000_Core_9_temp11_crit_alarm gauge
coretemp-isa-0000_Core_9_temp11_crit_alarm: 0.0
#HELP coretemp-isa-0000_Core_10_temp12_input coretemp-isa-0000 Core 10 temp12_input
#TYPE coretemp-isa-0000_Core_10_temp12_input gauge
coretemp-isa-0000_Core_10_temp12_input: 66.0
#HELP coretemp-isa-0000_Core_10_temp12_max coretemp-isa-0000 Core 10 temp12_max
#TYPE coretemp-isa-0000_Core_10_temp12_max gauge
coretemp-isa-0000_Core_10_temp12_max: 100.0
#HELP coretemp-isa-0000_Core_10_temp12_crit coretemp-isa-0000 Core 10 temp12_crit
#TYPE coretemp-isa-0000_Core_10_temp12_crit gauge
coretemp-isa-0000_Core_10_temp12_crit: 100.0
#HELP coretemp-isa-0000_Core_10_temp12_crit_alarm coretemp-isa-0000 Core 10 temp12_crit_alarm
#TYPE coretemp-isa-0000_Core_10_temp12_crit_alarm gauge
coretemp-isa-0000_Core_10_temp12_crit_alarm: 0.0
#HELP coretemp-isa-0000_Core_11_temp13_input coretemp-isa-0000 Core 11 temp13_input
#TYPE coretemp-isa-0000_Core_11_temp13_input gauge
coretemp-isa-0000_Core_11_temp13_input: 66.0
#HELP coretemp-isa-0000_Core_11_temp13_max coretemp-isa-0000 Core 11 temp13_max
#TYPE coretemp-isa-0000_Core_11_temp13_max gauge
coretemp-isa-0000_Core_11_temp13_max: 100.0
#HELP coretemp-isa-0000_Core_11_temp13_crit coretemp-isa-0000 Core 11 temp13_crit
#TYPE coretemp-isa-0000_Core_11_temp13_crit gauge
coretemp-isa-0000_Core_11_temp13_crit: 100.0
#HELP coretemp-isa-0000_Core_11_temp13_crit_alarm coretemp-isa-0000 Core 11 temp13_crit_alarm
#TYPE coretemp-isa-0000_Core_11_temp13_crit_alarm gauge
coretemp-isa-0000_Core_11_temp13_crit_alarm: 0.0
#HELP coretemp-isa-0000_Core_12_temp14_input coretemp-isa-0000 Core 12 temp14_input
#TYPE coretemp-isa-0000_Core_12_temp14_input gauge
coretemp-isa-0000_Core_12_temp14_input: 61.0
#HELP coretemp-isa-0000_Core_12_temp14_max coretemp-isa-0000 Core 12 temp14_max
#TYPE coretemp-isa-0000_Core_12_temp14_max gauge
coretemp-isa-0000_Core_12_temp14_max: 100.0
#HELP coretemp-isa-0000_Core_12_temp14_crit coretemp-isa-0000 Core 12 temp14_crit
#TYPE coretemp-isa-0000_Core_12_temp14_crit gauge
coretemp-isa-0000_Core_12_temp14_crit: 100.0
#HELP coretemp-isa-0000_Core_12_temp14_crit_alarm coretemp-isa-0000 Core 12 temp14_crit_alarm
#TYPE coretemp-isa-0000_Core_12_temp14_crit_alarm gauge
coretemp-isa-0000_Core_12_temp14_crit_alarm: 0.0
#HELP coretemp-isa-0000_Core_13_temp15_input coretemp-isa-0000 Core 13 temp15_input
#TYPE coretemp-isa-0000_Core_13_temp15_input gauge
coretemp-isa-0000_Core_13_temp15_input: 61.0
#HELP coretemp-isa-0000_Core_13_temp15_max coretemp-isa-0000 Core 13 temp15_max
#TYPE coretemp-isa-0000_Core_13_temp15_max gauge
coretemp-isa-0000_Core_13_temp15_max: 100.0
#HELP coretemp-isa-0000_Core_13_temp15_crit coretemp-isa-0000 Core 13 temp15_crit
#TYPE coretemp-isa-0000_Core_13_temp15_crit gauge
coretemp-isa-0000_Core_13_temp15_crit: 100.0
#HELP coretemp-isa-0000_Core_13_temp15_crit_alarm coretemp-isa-0000 Core 13 temp15_crit_alarm
#TYPE coretemp-isa-0000_Core_13_temp15_crit_alarm gauge
coretemp-isa-0000_Core_13_temp15_crit_alarm: 0.0
#HELP coretemp-isa-0000_Core_14_temp16_input coretemp-isa-0000 Core 14 temp16_input
#TYPE coretemp-isa-0000_Core_14_temp16_input gauge
coretemp-isa-0000_Core_14_temp16_input: 61.0
#HELP coretemp-isa-0000_Core_14_temp16_max coretemp-isa-0000 Core 14 temp16_max
#TYPE coretemp-isa-0000_Core_14_temp16_max gauge
coretemp-isa-0000_Core_14_temp16_max: 100.0
#HELP coretemp-isa-0000_Core_14_temp16_crit coretemp-isa-0000 Core 14 temp16_crit
#TYPE coretemp-isa-0000_Core_14_temp16_crit gauge
coretemp-isa-0000_Core_14_temp16_crit: 100.0
#HELP coretemp-isa-0000_Core_14_temp16_crit_alarm coretemp-isa-0000 Core 14 temp16_crit_alarm
#TYPE coretemp-isa-0000_Core_14_temp16_crit_alarm gauge
coretemp-isa-0000_Core_14_temp16_crit_alarm: 0.0
#HELP coretemp-isa-0000_Core_15_temp17_input coretemp-isa-0000 Core 15 temp17_input
#TYPE coretemp-isa-0000_Core_15_temp17_input gauge
coretemp-isa-0000_Core_15_temp17_input: 61.0
#HELP coretemp-isa-0000_Core_15_temp17_max coretemp-isa-0000 Core 15 temp17_max
#TYPE coretemp-isa-0000_Core_15_temp17_max gauge
coretemp-isa-0000_Core_15_temp17_max: 100.0
#HELP coretemp-isa-0000_Core_15_temp17_crit coretemp-isa-0000 Core 15 temp17_crit
#TYPE coretemp-isa-0000_Core_15_temp17_crit gauge
coretemp-isa-0000_Core_15_temp17_crit: 100.0
#HELP coretemp-isa-0000_Core_15_temp17_crit_alarm coretemp-isa-0000 Core 15 temp17_crit_alarm
#TYPE coretemp-isa-0000_Core_15_temp17_crit_alarm gauge
coretemp-isa-0000_Core_15_temp17_crit_alarm: 0.0
#HELP thinkpad-isa-0000_fan1_fan1_input thinkpad-isa-0000 fan1 fan1_input
#TYPE thinkpad-isa-0000_fan1_fan1_input gauge
thinkpad-isa-0000_fan1_fan1_input: 2191.0
#HELP thinkpad-isa-0000_CPU_temp1_input thinkpad-isa-0000 CPU temp1_input
#TYPE thinkpad-isa-0000_CPU_temp1_input gauge
thinkpad-isa-0000_CPU_temp1_input: 60.0
#HELP thinkpad-isa-0000_temp3_temp3_input thinkpad-isa-0000 temp3 temp3_input
#TYPE thinkpad-isa-0000_temp3_temp3_input gauge
thinkpad-isa-0000_temp3_temp3_input: 60.0
#HELP thinkpad-isa-0000_temp4_temp4_input thinkpad-isa-0000 temp4 temp4_input
#TYPE thinkpad-isa-0000_temp4_temp4_input gauge
thinkpad-isa-0000_temp4_temp4_input: 0.0
#HELP thinkpad-isa-0000_temp5_temp5_input thinkpad-isa-0000 temp5 temp5_input
#TYPE thinkpad-isa-0000_temp5_temp5_input gauge
thinkpad-isa-0000_temp5_temp5_input: 60.0
#HELP thinkpad-isa-0000_temp6_temp6_input thinkpad-isa-0000 temp6 temp6_input
#TYPE thinkpad-isa-0000_temp6_temp6_input gauge
thinkpad-isa-0000_temp6_temp6_input: 60.0
#HELP thinkpad-isa-0000_temp7_temp7_input thinkpad-isa-0000 temp7 temp7_input
#TYPE thinkpad-isa-0000_temp7_temp7_input gauge
thinkpad-isa-0000_temp7_temp7_input: 60.0
#HELP ucsi_source_psy_USBC000:001-isa-0000_in0_in0_input ucsi_source_psy_USBC000:001-isa-0000 in0 in0_input
#TYPE ucsi_source_psy_USBC000:001-isa-0000_in0_in0_input gauge
ucsi_source_psy_USBC000:001-isa-0000_in0_in0_input: 0.0
#HELP ucsi_source_psy_USBC000:001-isa-0000_in0_in0_min ucsi_source_psy_USBC000:001-isa-0000 in0 in0_min
#TYPE ucsi_source_psy_USBC000:001-isa-0000_in0_in0_min gauge
ucsi_source_psy_USBC000:001-isa-0000_in0_in0_min: 0.0
#HELP ucsi_source_psy_USBC000:001-isa-0000_in0_in0_max ucsi_source_psy_USBC000:001-isa-0000 in0 in0_max
#TYPE ucsi_source_psy_USBC000:001-isa-0000_in0_in0_max gauge
ucsi_source_psy_USBC000:001-isa-0000_in0_in0_max: 0.0
#HELP ucsi_source_psy_USBC000:001-isa-0000_curr1_curr1_input ucsi_source_psy_USBC000:001-isa-0000 curr1 curr1_input
#TYPE ucsi_source_psy_USBC000:001-isa-0000_curr1_curr1_input gauge
ucsi_source_psy_USBC000:001-isa-0000_curr1_curr1_input: 0.0
#HELP ucsi_source_psy_USBC000:001-isa-0000_curr1_curr1_max ucsi_source_psy_USBC000:001-isa-0000 curr1 curr1_max
#TYPE ucsi_source_psy_USBC000:001-isa-0000_curr1_curr1_max gauge
ucsi_source_psy_USBC000:001-isa-0000_curr1_curr1_max: 0.0
#HELP BAT0-acpi-0_in0_in0_input BAT0-acpi-0 in0 in0_input
#TYPE BAT0-acpi-0_in0_in0_input gauge
BAT0-acpi-0_in0_in0_input: 12.909
#HELP BAT0-acpi-0_power1_power1_input BAT0-acpi-0 power1 power1_input
#TYPE BAT0-acpi-0_power1_power1_input gauge
BAT0-acpi-0_power1_power1_input: 0.0
#HELP iwlwifi_1-virtual-0_temp1_temp1_input iwlwifi_1-virtual-0 temp1 temp1_input
#TYPE iwlwifi_1-virtual-0_temp1_temp1_input gauge
iwlwifi_1-virtual-0_temp1_temp1_input: 43.0
#HELP ucsi_source_psy_USBC000:002-isa-0000_in0_in0_input ucsi_source_psy_USBC000:002-isa-0000 in0 in0_input
#TYPE ucsi_source_psy_USBC000:002-isa-0000_in0_in0_input gauge
ucsi_source_psy_USBC000:002-isa-0000_in0_in0_input: 0.0
#HELP ucsi_source_psy_USBC000:002-isa-0000_in0_in0_min ucsi_source_psy_USBC000:002-isa-0000 in0 in0_min
#TYPE ucsi_source_psy_USBC000:002-isa-0000_in0_in0_min gauge
ucsi_source_psy_USBC000:002-isa-0000_in0_in0_min: 0.0
#HELP ucsi_source_psy_USBC000:002-isa-0000_in0_in0_max ucsi_source_psy_USBC000:002-isa-0000 in0 in0_max
#TYPE ucsi_source_psy_USBC000:002-isa-0000_in0_in0_max gauge
ucsi_source_psy_USBC000:002-isa-0000_in0_in0_max: 0.0
#HELP ucsi_source_psy_USBC000:002-isa-0000_curr1_curr1_input ucsi_source_psy_USBC000:002-isa-0000 curr1 curr1_input
#TYPE ucsi_source_psy_USBC000:002-isa-0000_curr1_curr1_input gauge
ucsi_source_psy_USBC000:002-isa-0000_curr1_curr1_input: 3.0
#HELP ucsi_source_psy_USBC000:002-isa-0000_curr1_curr1_max ucsi_source_psy_USBC000:002-isa-0000 curr1 curr1_max
#TYPE ucsi_source_psy_USBC000:002-isa-0000_curr1_curr1_max gauge
ucsi_source_psy_USBC000:002-isa-0000_curr1_curr1_max: 0.0
#HELP nvme-pci-0200_Composite_temp1_input nvme-pci-0200 Composite temp1_input
#TYPE nvme-pci-0200_Composite_temp1_input gauge
nvme-pci-0200_Composite_temp1_input: 45.85
#HELP nvme-pci-0200_Composite_temp1_max nvme-pci-0200 Composite temp1_max
#TYPE nvme-pci-0200_Composite_temp1_max gauge
nvme-pci-0200_Composite_temp1_max: 85.85
#HELP nvme-pci-0200_Composite_temp1_min nvme-pci-0200 Composite temp1_min
#TYPE nvme-pci-0200_Composite_temp1_min gauge
nvme-pci-0200_Composite_temp1_min: -273.15
#HELP nvme-pci-0200_Composite_temp1_crit nvme-pci-0200 Composite temp1_crit
#TYPE nvme-pci-0200_Composite_temp1_crit gauge
nvme-pci-0200_Composite_temp1_crit: 86.85
#HELP nvme-pci-0200_Composite_temp1_alarm nvme-pci-0200 Composite temp1_alarm
#TYPE nvme-pci-0200_Composite_temp1_alarm gauge
nvme-pci-0200_Composite_temp1_alarm: 0.0
#HELP nvme-pci-0200_Sensor_1_temp2_input nvme-pci-0200 Sensor 1 temp2_input
#TYPE nvme-pci-0200_Sensor_1_temp2_input gauge
nvme-pci-0200_Sensor_1_temp2_input: 50.85
#HELP nvme-pci-0200_Sensor_1_temp2_max nvme-pci-0200 Sensor 1 temp2_max
#TYPE nvme-pci-0200_Sensor_1_temp2_max gauge
nvme-pci-0200_Sensor_1_temp2_max: 65261.85
#HELP nvme-pci-0200_Sensor_1_temp2_min nvme-pci-0200 Sensor 1 temp2_min
#TYPE nvme-pci-0200_Sensor_1_temp2_min gauge
nvme-pci-0200_Sensor_1_temp2_min: -273.15
#HELP nvme-pci-0200_Sensor_2_temp3_input nvme-pci-0200 Sensor 2 temp3_input
#TYPE nvme-pci-0200_Sensor_2_temp3_input gauge
nvme-pci-0200_Sensor_2_temp3_input: 45.85
#HELP nvme-pci-0200_Sensor_2_temp3_max nvme-pci-0200 Sensor 2 temp3_max
#TYPE nvme-pci-0200_Sensor_2_temp3_max gauge
nvme-pci-0200_Sensor_2_temp3_max: 65261.85
#HELP nvme-pci-0200_Sensor_2_temp3_min nvme-pci-0200 Sensor 2 temp3_min
#TYPE nvme-pci-0200_Sensor_2_temp3_min gauge
nvme-pci-0200_Sensor_2_temp3_min: -273.15
#HELP acpitz-acpi-0_temp1_temp1_input acpitz-acpi-0 temp1 temp1_input
#TYPE acpitz-acpi-0_temp1_temp1_input gauge
acpitz-acpi-0_temp1_temp1_input: 60.0  
  

Os dados aparecem no #HELP simplesmente sem os _. Talvez eu devesse melhorar e fazer com que alguns valores fosse labels do valor maior. Mas por enquanto está funcionando assim.

E pra rodar como serviço:

  
❯ ./sensors-open-metrics.py 
[2025-12-12 19:54:43] (INFO) starting service on port 9090
INFO:     Started server process [1078630]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://127.0.0.1:9090 (Press CTRL+C to quit)
[2025-12-12 19:54:53] (INFO) serving web page on /metrics
INFO:     127.0.0.1:52250 - "GET /metrics HTTP/1.1" 200 OK    
  

Em outro terminal:

  
  ❯ curl localhost:9090/metrics
#HELP coretemp-isa-0000_Package_id_0_temp1_input coretemp-isa-0000 Package id 0 temp1_input
#TYPE coretemp-isa-0000_Package_id_0_temp1_input gauge
coretemp-isa-0000_Package_id_0_temp1_input: 62.0
#HELP coretemp-isa-0000_Package_id_0_temp1_max coretemp-isa-0000 Package id 0 temp1_max
#TYPE coretemp-isa-0000_Package_id_0_temp1_max gauge
coretemp-isa-0000_Package_id_0_temp1_max: 100.0
#HELP coretemp-isa-0000_Package_id_0_temp1_crit coretemp-isa-0000 Package id 0 temp1_crit
#TYPE coretemp-isa-0000_Package_id_0_temp1_crit gauge
coretemp-isa-0000_Package_id_0_temp1_crit: 100.0
#HELP coretemp-isa-0000_Package_id_0_temp1_crit_alarm coretemp-isa-0000 Package id 0 temp1_crit_alarm
[...]  
  

Pra rodar como serviço do systemd em /etc/systemd/system/sensors-open-metrics.service:

  
[Unit]
Description=Temperature sensors
Wants=network-online.target
After=network-online.target nginx.service

[Service]
Restart=always
User=root
Group=root
WorkingDirectory=/tmp
ExecStart=/usr/local/bin/sensors-open-metrics.py
TimeoutStopSec=5s

[Install]
WantedBy=multi-user.target    
  

E finalmente adicionando em /etc/alloy/config.alloy:

  
[...]
    discovery.relabel "temperature_metrics" {
        targets = array.concat(
                [{
                        __address__ = "localhost:9090",
                }],
        )

        rule {
                source_labels = ["__address__"]
                target_label  = "instance"
                replacement   = "nome_do_servidor"
        }
}

prometheus.scrape "temperature_metrics" {
        targets    = discovery.relabel.temperature_metrics.output
        forward_to = [prometheus.remote_write.prod.receiver]
        job_name   = "temperature"
}
  

Daí é só evocar o Grafana e partir por gráficos.

Update: atualizado em Sat Dec 13 02:43:59 PM CET 2025 com mais informação sobre a parte de script.