A service on a device
This example builds a service for a small Linux machine, such as a Raspberry Pi or a gateway: it reads the load, the memory and the temperature of the machine every 10 seconds, keeps the last hour of readings in memory, and serves them over HTTP. It starts from the service template with its installer feature, and shows a scheduled action, components of its own with their options, the lakutata monitors, configuration through environment variables, and the packages that install the service on the machine and start it with the system.
| Method | Route | What it does |
|---|---|---|
GET | /readings?limit=10 | Gives the latest readings, the newest first |
GET | /status | Gives the uptime of the service, its number of readings, and its own CPU and memory use |
GET | /health | Tells whether the process answers (from the template) |
1. Create the project
npx @lakutata/cli create --name telemetry --template service --with installer --yes
cd telemetry
rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.tsThe project is a workspace whose service is packages/app (ProjectStructure). The installer feature adds what is needed to make the installers: the lakutata.installer configuration in packages/app/package.json, the package:host and package:linux scripts, Dockerfile.installer, @lakutata/builder, and the icon in packages/resources. The service needs no other package: the schedules and the monitors are part of lakutata. The example controller of the template is removed.
2. The sensors
packages/app/src/components/Sensors.ts
import os from 'node:os'
import {readFile} from 'node:fs/promises'
import {Component, DTO} from 'lakutata'
import {Configurable} from 'lakutata/decorator/di'
/**
* What the sensors measure
*/
export type Measures = {
/**
* The load average of the machine over the last minute
*/
load: number
/**
* The memory in use, as a percentage of the memory of the machine
*/
memory: number
/**
* The temperature of the CPU in degrees Celsius, or null when the system does not report it
*/
temperature: number | null
}
/**
* The sensors of the machine: its load, its memory and the temperature of its CPU. On a device with its own sensors
* (on a serial port or I2C), this is the component that reads them
*/
export class Sensors extends Component {
/**
* The temperature file, in thousandths of a degree (a Linux thermal zone)
*/
@Configurable(DTO.String().required())
protected readonly temperatureFile: string
/**
* Read the sensors
*/
public async read(): Promise<Measures> {
return {
load: Math.round(os.loadavg()[0] * 100) / 100,
memory: Math.round((1 - os.freemem() / os.totalmem()) * 1000) / 10,
temperature: await this.temperature()
}
}
/**
* The temperature, or null when its file cannot be read (macOS, Windows, a machine without a thermal zone)
*/
protected async temperature(): Promise<number | null> {
try {
return Math.round(Number(await readFile(this.temperatureFile, 'utf8')) / 100) / 10
} catch {
return null
}
}
}A component is a long-lived object, one for the whole application (Objects). This one:
- reads the load average over the last minute (
os.loadavg(), always 0 on Windows), and the share of the memory in use; - reads the temperature of the CPU from a Linux thermal zone: a file that holds thousandths of a degree (
48312for 48.3 °C). On a machine without one, such as a Mac, the temperature isnull; - has an option,
temperatureFile: when the component is created,@Configurablechecks the value given in the options of the service against its schema.
On a device with its own sensors (on a serial port or I2C), this is the component that reads them; the rest of the service stays the same.
3. The readings
packages/app/src/components/Readings.ts
import {Component, DTO} from 'lakutata'
import {Configurable} from 'lakutata/decorator/di'
import {type Measures} from './Sensors.js'
/**
* A reading: the measures of the sensors, and when they were taken
*/
export type Reading = { time: string } & Measures
/**
* The recent readings, kept in memory, at most size of them: a new one replaces the oldest
*/
export class Readings extends Component {
/**
* How many readings are kept
*/
@Configurable(DTO.Number().integer().min(1).required())
protected readonly size: number
protected readonly readings: Reading[] = []
/**
* The number of readings kept
*/
public get count(): number {
return this.readings.length
}
/**
* Keep a reading
* @param reading
*/
public add(reading: Reading): void {
this.readings.push(reading)
if (this.readings.length > this.size) this.readings.shift()
}
/**
* The latest readings, the newest first
* @param limit
*/
public latest(limit: number): Reading[] {
return this.readings.slice(-limit).reverse()
}
}- The readings are kept in memory, at most
sizeof them (an option, liketemperatureFile): a new reading replaces the oldest one. They are lost when the service restarts; section 11 shows how to keep them. - A component is a singleton: the scheduled action that adds the readings and the HTTP actions that read them share the same instance, although each run and each request gets its own controller.
4. The input
packages/app/src/dtos/ReadingsDTO.ts
import {DTO} from 'lakutata'
import {Expect} from 'lakutata/decorator/dto'
/**
* How many of the latest readings to return (the query string is text, which strict(false) converts)
*/
export class ReadingsDTO extends DTO {
@Expect(DTO.Number().integer().min(1).max(1000).strict(false).default(10))
public limit: number
}limit is the number of readings to return: from 1 to 1000, 10 by default. The query string is text: .strict(false) converts '3' to the number 3, and an invalid value is refused before the action runs (DTOValidation).
5. The controller
packages/app/src/controllers/ReadingController.ts
import {Controller, type ScheduleContext} from 'lakutata/com/entrypoint'
import {type CpuMonitor, type MemoryMonitor} from 'lakutata/com/monitor'
import {GET, Schedule} from 'lakutata/decorator/ctrl'
import {Inject} from 'lakutata/decorator/di'
import {type Reading, type Readings} from '../components/Readings.js'
import {type Sensors} from '../components/Sensors.js'
import {ReadingsDTO} from '../dtos/ReadingsDTO.js'
export class ReadingController extends Controller {
@Inject('sensors')
protected readonly sensors: Sensors
@Inject('readings')
protected readonly readings: Readings
/**
* The CPU and the memory of the service itself
*/
@Inject('cpu')
protected readonly cpu: CpuMonitor
@Inject('memory')
protected readonly memory: MemoryMonitor
/**
* Reads the sensors at the start, then every SAMPLE_EVERY (10 seconds by default)
*/
@Schedule({every: process.env.SAMPLE_EVERY ?? '10s', immediate: true})
public async sample(): Promise<void> {
const context: ScheduleContext = this.context as ScheduleContext
this.readings.add({time: context.scheduledAt.toISOString(), ...await this.sensors.read()})
}
/**
* GET /readings?limit=10: the latest readings, the newest first
* @param input
*/
@GET('/readings', ReadingsDTO)
public async list(input: ReadingsDTO): Promise<Reading[]> {
return this.readings.latest(input.limit)
}
/**
* GET /status: the service itself, as measured by the monitors since it started
*/
@GET('/status')
public async status(): Promise<{ uptime: number, readings: number, cpu: number, memory: number }> {
return {
uptime: Math.round(process.uptime()),
readings: this.readings.count,
//The mean CPU usage, in percent of one core
cpu: this.cpu.statistics.usageAvg,
//The resident memory, in bytes
memory: this.memory.statistics.physicalUsed
}
}
}- A controller can hold the actions of several transports (Controllers):
sample()is a scheduled action,list()andstatus()are HTTP actions. Each run ofsample()is handled like a request, with a new controller. @Schedule({every, immediate: true})runssample()once at the start, then once every interval (Schedules).this.contextis theScheduleContextof the run: itsscheduledAt, the time the run was planned for, becomes the time of the reading.- The interval is read from
SAMPLE_EVERYwhen the class is defined, that is, when its module is loaded, so it comes from the environment the process starts with (the shell, or the environment file of the installed service). A value that is not a duration, such as30without a unit, stops the service at startup:SyntaxError: Invalid duration "30": such as "500ms", "30s", "5m", "1h30m" or "1d". - The monitors measure the service itself, not the machine (Monitor):
usageAvgis the mean CPU usage of the process since its start, in percent of one core, andphysicalUsedis its resident memory, in bytes. They show whether the service stays light on a small machine.
6. The options of the service
packages/app/src/config/Config.ts
import type {ApplicationOptions} from 'lakutata'
import {buildEntrypoints, scheduleEntrypoint} from 'lakutata/com/entrypoint'
import {expressHTTPEntrypoint, jsonEnvelope} from 'lakutata/adapter/express'
import {CpuMonitor, MemoryMonitor} from 'lakutata/com/monitor'
import compression from 'compression'
import {authenticate} from '../middlewares/Authenticate.js'
import {Readings} from '../components/Readings.js'
import {Sensors} from '../components/Sensors.js'
import {HealthController} from '../controllers/HealthController.js'
import {ReadingController} from '../controllers/ReadingController.js'
/**
* The options of the service
* @param port the port of the HTTP server, the PORT environment variable or 8080 by default
* @param kept the number of readings kept, the READINGS_KEPT environment variable or 360 by default (an hour, with a
* reading every 10 seconds)
*/
export function config(port: number = Number(process.env.PORT ?? 8080), kept: number = Number(process.env.READINGS_KEPT ?? 360)): ApplicationOptions {
return {
id: 'telemetry',
name: 'telemetry',
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
components: {
//The monitors of the service's own CPU and memory
cpu: {class: CpuMonitor},
memory: {class: MemoryMonitor},
sensors: {class: Sensors, temperatureFile: process.env.TEMPERATURE_FILE ?? '/sys/class/thermal/thermal_zone0/temp'},
readings: {class: Readings, size: kept},
entrypoint: buildEntrypoints({
controllers: [HealthController, ReadingController],
//Runs the @Schedule actions
schedule: scheduleEntrypoint(),
http: expressHTTPEntrypoint({
port: port,
//The results and the errors are {errno, errMsg, data}, with the status 200
respond: jsonEnvelope({errorStatus: 200}),
body: {limit: '1mb'},
server: {keepAliveTimeout: 60000, headersTimeout: 65000},
middlewares: [authenticate],
before: (app): void => void app.use(compression())
})
})
},
//The monitors measure from the start
bootstrap: ['cpu', 'memory', 'entrypoint']
}
}This is the Config.ts of the template, with:
- the monitors
cpuandmemory, listed inbootstrapso that they measure from the start; - the
sensorsandreadingscomponents, with their options; ReadingControllerinstead of the example controller, and the schedule adapter,scheduleEntrypoint(), without which the@Scheduleactions never run (Entrypoint);- a limit of 1 MB on the request bodies instead of 100 MB: the API only reads query strings.
The service is configured through environment variables:
| Variable | Default | What it sets |
|---|---|---|
PORT | 8080 | The port of the HTTP API |
SAMPLE_EVERY | 10s | The interval between the readings: a duration such as 500ms, 30s or 5m |
READINGS_KEPT | 360 | How many readings are kept |
TEMPERATURE_FILE | /sys/class/thermal/thermal_zone0/temp | The temperature file |
API_TOKEN | None | When it is set, the requests must carry Authorization: Bearer <API_TOKEN> (the middleware of the template) |
MODE | development | production for the installed service; it wins over the mode option (Application) |
An invalid value stops the service at startup, and the log says why:
READINGS_KEPT=0 npm start[2026-10-17T14:55:03.108Z] ERROR (telemetry): The service cannot start: The option "size" of "readings" (Readings) is invalid: "size" must be greater than or equal to 17. The installers
packages/app/package.json
{
"name": "app",
"version": "0.0.1",
"description": "Reads the load, the memory and the temperature of the machine, and serves them over HTTP",
"author": "",
"license": "UNLICENSED",
"private": true,
"type": "module",
"main": "build/App.js",
"scripts": {
"clean": "lakutata clean",
"build": "tsc",
"rebuild": "npm run clean && npm run build",
"dev": "npm run rebuild && node build/App.js",
"start": "node build/App.js",
"test": "node --test \"build/tests/**/*.spec.js\"",
"package": "lakutata package installer"
},
"dependencies": {
"compression": "^1.8.1",
"express": "^5.2.1",
"lakutata": "^3.0.0"
},
"devDependencies": {
"@types/compression": "^1.8.1",
"@types/express": "^5.0.6"
},
"bin": {
"telemetry": "build/App.js"
},
"lakutata": {
"icon": "../resources/logo.png",
"installer": {
"name": "telemetry",
"service": {
"env": {
"PORT": "8080",
"SAMPLE_EVERY": "10s",
"READINGS_KEPT": "360"
},
"restart": "always"
},
"linux": {
"targets": [
"deb",
"rpm",
"tar.gz"
]
}
}
}
}This is the package.json made by the installer feature, with a description and the options of the service added (Installers):
binnames the program,telemetry, which the packages put in the PATH (/usr/bin/telemetryon Linux);descriptiondescribes the packages and the service;serviceregisters the service with the system: systemd on Linux, launchd on macOS, a Windows service on Windows. The installation enables and starts it, an upgrade restarts it, and the removal stops and disables it;service.envis the environment of the service, written to its environment file (section 10);restart: "always"restarts the service whenever it ends; the default,on-failure, restarts it only when it fails;- the service runs as
telemetry, a system user that the installation creates, in its working directory,/var/lib/telemetry, where it can write its data; linux.targetslists the Linux packages: deb (Debian, Ubuntu, Raspberry Pi OS), rpm (Fedora, Red Hat) and a tar.gz archive, which holds only the application, without the service.
The packages take their maintainer from lakutata.installer.maintainer, or else from the author of the package.json; without one, the tool warns.
8. Run it
SAMPLE_EVERY=2s npm run devIn another terminal:
curl 'localhost:8080/readings?limit=3'
# {"errno":null,"errMsg":null,"data":[{"time":"2026-10-17T14:50:33.497Z","load":1.94,"memory":75.8,"temperature":null},{"time":"2026-10-17T14:50:31.497Z","load":1.94,"memory":75.8,"temperature":null},{"time":"2026-10-17T14:50:29.497Z","load":1.94,"memory":75.9,"temperature":null}]}
curl localhost:8080/status
# {"errno":null,"errMsg":null,"data":{"uptime":6,"readings":3,"cpu":0.29407,"memory":117686272}}
curl 'localhost:8080/readings?limit=abc'
# {"errno":"E_INVALID_VALUE","errMsg":"\"limit\" must be a number","data":null}The first reading is taken at startup, then one every 2 seconds. The temperature is null here because the service ran on a Mac; on a Linux machine with a thermal zone, it is the temperature of its CPU in degrees Celsius.
9. Test it
The test of the template is replaced by a test of the readings. It writes its own thermal zone, a file that reads 42.5 degrees, and starts the service with a reading every 200 milliseconds, keeping 3 readings:
packages/app/src/tests/App.spec.ts
import {after, before, describe, it} from 'node:test'
import assert from 'node:assert'
import {mkdtemp, rm, writeFile} from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import {setTimeout as sleep} from 'node:timers/promises'
import {Application} from 'lakutata'
const PORT: number = Number(process.env.TEST_PORT ?? 18089)
/**
* A request to the service, with the token: its {errno, errMsg, data}
* @param route
*/
async function request(route: string): Promise<{ errno: string | null, data: any }> {
const response: Response = await fetch(`http://127.0.0.1:${PORT}${route}`, {headers: {Authorization: 'Bearer test-token'}})
return await response.json() as { errno: string | null, data: any }
}
describe('telemetry', (): void => {
let app: Application
let directory: string
before(async (): Promise<void> => {
//A thermal zone of 42.5 degrees
directory = await mkdtemp(path.join(os.tmpdir(), 'telemetry-'))
await writeFile(path.join(directory, 'temp'), '42500\n')
process.env.TEMPERATURE_FILE = path.join(directory, 'temp')
process.env.API_TOKEN = 'test-token'
//A reading every 200 milliseconds. The interval is read when the controller is loaded, so the options are
//imported only after SAMPLE_EVERY is set
process.env.SAMPLE_EVERY = '200ms'
const {config} = await import('../config/Config.js')
app = await new Promise<Application>((resolve: (app: Application) => void): void => {
Application.run(config(PORT, 3)).onLaunched((launched: Application): void => resolve(launched))
})
})
after(async (): Promise<void> => {
await app?.dispose()
await rm(directory, {recursive: true, force: true})
})
it('refuses the requests without authentication', async (): Promise<void> => {
const response: Response = await fetch(`http://127.0.0.1:${PORT}/readings`)
assert.deepStrictEqual(await response.json(), {errno: 'E_UNAUTHORIZED', errMsg: 'The request is not authenticated', data: null})
})
it('samples the sensors on its schedule and keeps the latest readings', async (): Promise<void> => {
//A reading at the start, then one every 200 milliseconds: the latest 3 are kept
await sleep(1000)
const {data: readings} = await request('/readings')
assert.strictEqual(readings.length, 3)
assert.ok(readings[0].time > readings[1].time && readings[1].time > readings[2].time)
for (const reading of readings) {
assert.strictEqual(reading.temperature, 42.5)
assert.ok(reading.load >= 0)
assert.ok(reading.memory > 0 && reading.memory <= 100)
}
assert.strictEqual((await request('/readings?limit=1')).data.length, 1)
})
it('refuses an invalid limit', async (): Promise<void> => {
assert.strictEqual((await request('/readings?limit=0')).errno, 'E_INVALID_VALUE')
})
it('gives the status of the service', async (): Promise<void> => {
const {data: status} = await request('/status')
assert.strictEqual(status.readings, 3)
assert.ok(status.cpu >= 0)
assert.ok(status.memory > 0)
})
})The options are imported only after SAMPLE_EVERY is set, because the interval is read when the module of the controller is loaded. node --test runs each test file in its own process, so the variables that a file sets do not reach the others.
npm testapp │ ✔ refuses the requests without authentication
app │ ✔ samples the sensors on its schedule and keeps the latest readings
app │ ✔ refuses an invalid limit
app │ ✔ gives the status of the service
app │ ℹ pass 4
✔ test ran in app10. Install it on the device
The installers hold an executable that embeds Node.js, built for the system and the architecture of the machine that makes them (Packaging). The two scripts of the installer feature write the installers to dist/installer:
npm run package:linuxmakes the Linux packages in a Docker container (Dockerfile.installer), on macOS and Windows too, for the architecture of Docker: arm64 on a Mac with Apple silicon, which is also the architecture of a Raspberry Pi 4 or 5 running a 64-bit system. The file names hold the name of the package, its version and its architecture:telemetry_0.0.1_arm64.deb,telemetry-0.0.1-1.aarch64.rpmandtelemetry-0.0.1-linux-arm64.tar.gz. For x64 machines, give Docker their platform:docker buildx build --platform linux/amd64 --file Dockerfile.installer --output type=local,dest=dist/installer .npm run package:hostmakes the installers for the machine it runs on: the same packages on Linux; on macOS, apkgpackage (a launchd service) and a tar.gz archive; on Windows, the NSIS and MSI installers (a Windows service) and a zip archive.
On a device running Debian or Raspberry Pi OS:
sudo apt install ./telemetry_0.0.1_arm64.deb
systemctl status telemetry # enabled and started
journalctl -u telemetry -f # its log
curl 'localhost:8080/readings?limit=1'The environment file of the service, /etc/telemetry/telemetry.env, holds MODE=production and the service.env of the package.json:
# The environment of the service telemetry, read when it starts (systemctl restart telemetry)
MODE="production"
PORT="8080"
SAMPLE_EVERY="10s"
READINGS_KEPT="360"To change the configuration of the service, edit the file, then restart the service:
sudoedit /etc/telemetry/telemetry.env # SAMPLE_EVERY="30s", API_TOKEN="…"
sudo systemctl restart telemetryAn upgrade (sudo apt install of the next version) keeps your edits to the environment file and restarts the service. sudo apt remove telemetry stops the service and removes the application, but keeps the environment file and the working directory.
11. Next steps
- To keep the readings across restarts, store them in a SQLite database in the working directory of the service, with a relative path (once installed,
readings.sqliteis/var/lib/telemetry/readings.sqlite), as in A REST API with a database. Its driver, better-sqlite3, is a native addon, which the installers ship next to the executable (Packaging, section 3). - To send the readings to a server, publish them from
sample()over NATS, or add them to a queue. - On a Linux device, the service can also work with the system: call system services over D-Bus, or run containers with containerd or Docker.