Compare commits

..

No commits in common. "develop" and "v1.0.1" have entirely different histories.

14 changed files with 4236 additions and 5773 deletions

1
.envrc
View file

@ -1 +0,0 @@
use flake

1
.github/FUNDING.yml vendored
View file

@ -1,2 +1 @@
github: lstrojny
custom: ["https://paypal.me/larsstrojny"]

View file

@ -20,16 +20,16 @@ jobs:
name: nodejs ${{ matrix.node-version }} (${{ matrix.lint && 'lint → ' || '' }}${{ matrix.tests && 'test → ' || '' }}build)
steps:
- uses: actions/checkout@v4.1.0
- uses: actions/checkout@v3.2.0
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3.8.1
uses: actions/setup-node@v3.5.1
with:
node-version: ${{ matrix.node-version }}
- name: Cache node modules
id: cache-npm
uses: actions/cache@v3.3.2
uses: actions/cache@v3.0.11
env:
cache-name: cache-node-modules
with:
@ -38,7 +38,7 @@ jobs:
- name: Cache eslint
id: cache-eslint
uses: actions/cache@v3.3.2
uses: actions/cache@v3.0.11
env:
cache-name: cache-eslint
with:
@ -47,7 +47,7 @@ jobs:
- name: Cache TypeScript
id: cache-typescript
uses: actions/cache@v3.3.2
uses: actions/cache@v3.0.11
env:
cache-name: cache-typescript
with:
@ -56,7 +56,7 @@ jobs:
- name: Cache prettier
id: cache-prettier
uses: actions/cache@v3.3.2
uses: actions/cache@v3.0.11
env:
cache-name: cache-prettier
with:
@ -76,7 +76,7 @@ jobs:
if: ${{ matrix.tests }}
- name: Upload code coverage
uses: actions/upload-artifact@v3.1.3
uses: actions/upload-artifact@v3.1.1
with:
name: code-coverage
path: coverage/lcov.info

View file

@ -1,28 +0,0 @@
name: Dependabot auto merge
on:
workflow_run:
workflows: [CI]
types:
- completed
jobs:
automerge:
name: Auto merge "${{ github.event.workflow_run.head_branch }}"
runs-on: ubuntu-22.04
if: >
github.event.workflow_run.event == 'pull_request'
&& github.event.workflow_run.conclusion == 'success'
&& github.actor == 'dependabot[bot]'
&& startsWith(github.event.workflow_run.head_branch, 'dependabot/')
steps:
- name: Checkout source
uses: actions/checkout@v4.1.0
with:
ref: ${{ github.event.workflow_run.head_commit.id }}
- name: Instruct @dependabot to merge
run: "gh issue comment $ISSUE_ID --body \"(This is an automated comment from workflow $WORKFLOW_URL)\n\n@dependabot squash and merge\""
env:
GITHUB_TOKEN: ${{ secrets.DEPENDABOT_COMMENT_TOKEN }}
ISSUE_ID: ${{ github.event.workflow_run.pull_requests[0].number }}
WORKFLOW_URL: ${{ github.event.repository.html_url }}/actions/runs/${{ github.run_id }}

View file

@ -1,4 +1,4 @@
name: Sonar scan
name: Sonar
on:
workflow_run:
@ -7,12 +7,12 @@ on:
jobs:
sonar:
name: Sonar scan on "${{ github.event.workflow_run.head_branch }}"
name: Sonar
runs-on: ubuntu-latest
if: github.event.workflow_run.conclusion == 'success'
steps:
- uses: actions/checkout@v4.1.0
- uses: actions/checkout@v3
with:
repository: ${{ github.event.workflow_run.head_repository.full_name }}
ref: ${{ github.event.workflow_run.head_branch }}

View file

@ -133,22 +133,16 @@ Once *Prometheus* is restarted, metrics with the `homebridge_` prefix should sta
// Debug
//
// Default: false
"debug": "<boolean>",
// Metrics prefix
//
// Default: "homebridge"
"prefix": "<string>",
// Metrics server port
//
// TCP port where the Prometheus metrics server listens
//
// Default: 36123
"port": "<integer>",
@ -157,32 +151,24 @@ Once *Prometheus* is restarted, metrics with the `homebridge_` prefix should sta
// Interface where the Prometheus metrics server listens. Can be an IP, a
// hostname, "0.0.0.0" for all IPv4 interfaces, "::1" for all IPv6 interfaces.
// Default is "::" which means "any interface"
//
// Default: "::"
"interface": "<string>",
// Service refresh interval
//
// Discover new services every <interval> seconds
//
// Default: 60
"refresh_interval": "<integer>",
// Request timeout
//
// Request timeout when interacting with homebridge instances
//
// Default: 10
"request_timeout": "<integer>",
// Service discovery timeout
//
// Discovery timeout after which the current discovery is considered failed
//
// Default: 20
"discovery_timeout": "<integer>",

View file

@ -56,9 +56,6 @@ function generateDocs(schema) {
if (definition.description) {
lines.push(`//\n// ${wordwrap(definition.description, 80, '\n// ')}`)
}
if (definition.default !== undefined) {
lines.push(`//\n// Default: ${JSON.stringify(definition.default)}`)
}
lines.push(`${JSON.stringify(property)}: ${JSON.stringify('<' + definition.type + '>')}`)

9812
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "homebridge-prometheus-exporter",
"version": "1.0.5",
"version": "1.0.1",
"description": "Prometheus exporter for homebridge accessories.",
"license": "Apache-2.0",
"repository": {
@ -16,10 +16,9 @@
},
"main": "dist/src/index.js",
"scripts": {
"_portable_exec": "npmPortableExec() { `npm root`/.bin/$@; }; npmPortableExec",
"lint": "ifNotCi() { test \"$CI\" && echo \"$2\" || echo \"$1\"; }; npm run _portable_exec -- tsc --noEmit && npm run _portable_exec -- prettier --ignore-path=.gitignore `ifNotCi --write \"--check --cache --cache-strategy content\"` '**/**.{ts,js,json}' && npm run _portable_exec -- eslint `ifNotCi --fix \"--cache --cache-strategy content\"` --ignore-path=.gitignore '**/**.{ts,js,json}'",
"lint": "ifNotCi() { test \"$CI\" && echo \"$2\" || echo \"$1\"; }; npx tsc --noEmit && npx prettier --ignore-path=.gitignore `ifNotCi --write \"--check --cache --cache-strategy content\"` '**/**.{ts,js,json}' && npx eslint `ifNotCi --fix \"--cache --cache-strategy content\"` --ignore-path=.gitignore '**/**.{ts,js,json}'",
"start": "npm run build && npm run link && nodemon",
"test": "ifNotCi() { test \"$CI\" && echo \"$2\" || echo \"$1\"; }; npm run code-generation && npm run _portable_exec -- jest `ifNotCi --watchAll --collect-coverage`",
"test": "ifNotCi() { test \"$CI\" && echo \"$2\" || echo \"$1\"; }; npm run code-generation && npx jest `ifNotCi --watchAll --collect-coverage`",
"link": "npm install --no-save file:///$PWD/",
"build": "rimraf ./dist .tsbuildinfo && npm run code-generation && tsc",
"code-generation": "./code-generation/hap-gen.js && ./code-generation/config-scheme-gen.js",
@ -35,11 +34,10 @@
"devDependencies": {
"@jest/globals": "^29.3.0",
"@types/bcrypt": "^5.0.0",
"@types/node": "^20.2.3",
"@types/node": "^18.11.9",
"@types/supertest": "^2.0.12",
"@typescript-eslint/eslint-plugin": "^5.42.0",
"@typescript-eslint/parser": "^5.42.0",
"array.prototype.flatmap": "^1.3.1",
"eslint": "^8.0.1",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.26.0",
@ -49,22 +47,21 @@
"homebridge-cmdswitch2": "^0.2.10",
"jest": "^29.3.0",
"json-schema-to-zod": "^0.6.0",
"nodemon": "^3.0.1",
"nodemon": "^2.0.13",
"prettier": "^2.7.1",
"release-it": "^16.0.0",
"rimraf": "^5.0.0",
"release-it": "^15.5.0",
"rimraf": "^3.0.2",
"supertest": "^6.3.1",
"ts-jest": "^29.0.3",
"ts-node": "^10.3.0",
"typescript": "^5.0.2"
"typescript": "^4.4.4"
},
"dependencies": {
"@fastify/auth": "^4.1.0",
"@fastify/basic-auth": "^5.0.0",
"array.prototype.group": "^1.1.2",
"bcrypt": "^5.1.0",
"fastify": "^4.9.2",
"hap-node-client": "^0.2.1",
"hap-node-client": "^0.1.25",
"zod": "^3.19.1"
}
}

10
src/ambient.d.ts vendored
View file

@ -14,11 +14,7 @@ declare module 'hap-node-client' {
}
}
declare module 'array.prototype.group' {
function shim(): void
}
interface Array<T> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
group<U>(fn: (value: T, index: number, array: T[]) => U, thisArg?: any): { U: T[] }
// Workaround for "node_modules/hap-nodejs/dist/lib/Advertiser.d.ts:5:29 - error TS7016: Could not find a declaration file for module '@homebridge/dbus-native'. '…/node_modules/@homebridge/dbus-native/index.js' implicitly has an 'any' type."
declare module '@homebridge/dbus-native' {
type InvokeError = unknown
}

View file

@ -2,8 +2,6 @@ import type { Logger } from 'homebridge'
import type { HttpConfig, HttpResponse, HttpServer } from './adapters/http'
import type { Metric } from './metrics'
import { strTrimRight } from './std'
import { shim } from 'array.prototype.group'
shim()
export class MetricsRenderer {
private readonly prefix: string
@ -12,26 +10,17 @@ export class MetricsRenderer {
this.prefix = strTrimRight(prefix, '_')
}
render(metrics: Metric[]): string {
return (
Object.entries(metrics.sort().group((metric) => this.metricName(metric.name)))
.map(([name, metrics]) => {
return [
`# TYPE ${name} ${name.endsWith('_total') ? 'counter' : 'gauge'}`,
metrics.map((metric) => this.formatMetric(metric)).join('\n'),
].join('\n')
})
.join('\n\n') + '\n'
)
render(metric: Metric): string {
const name = this.metricName(metric.name)
return [
`# TYPE ${name} ${name.endsWith('_total') ? 'counter' : 'gauge'}`,
`${name}${this.renderLabels(metric.labels)} ${metric.value}${
metric.timestamp !== null ? ' ' + String(metric.timestamp.getTime()) : ''
}`,
].join('\n')
}
private formatMetric(metric: Metric): string {
return `${this.metricName(metric.name)}${MetricsRenderer.renderLabels(metric.labels)} ${metric.value}${
metric.timestamp !== null ? ' ' + String(metric.timestamp.getTime()) : ''
}`
}
private static renderLabels(labels: Metric['labels']): string {
private renderLabels(labels: Metric['labels']): string {
const rendered = Object.entries(labels)
.map(([label, val]) => `${sanitizePrometheusMetricName(label)}="${escapeAttributeValue(val)}"`)
.join(',')
@ -102,7 +91,7 @@ export class PrometheusServer implements HttpServer {
}
onMetricsDiscovery(metrics: Metric[]): void {
this.metricsResponse = this.renderer.render(metrics)
this.metricsResponse = metrics.map((metric) => this.renderer.render(metric)).join('\n')
this.metricsDiscovered = true
}
}

View file

@ -71,10 +71,8 @@ describe('Fastify HTTP adapter', () => {
[
'# TYPE homebridge_metric gauge',
'homebridge_metric{name="metric"} 0.1 1577836800000',
'',
'# TYPE homebridge_something_total counter',
'homebridge_something_total{name="counter"} 100 1577836800000',
'',
].join('\n'),
)
})
@ -119,10 +117,8 @@ describe('Fastify HTTP adapter', () => {
[
'# TYPE homebridge_metric gauge',
'homebridge_metric{name="metric"} 0.1 1577836800000',
'',
'# TYPE homebridge_something_total counter',
'homebridge_something_total{name="counter"} 100 1577836800000',
'',
].join('\n'),
)
})

View file

@ -6,94 +6,68 @@ describe('Render prometheus metrics', () => {
const renderer = new MetricsRenderer('prefix')
test('Renders simple metric', () => {
expect(renderer.render([new Metric('metric', 0.000001)])).toEqual(
expect(renderer.render(new Metric('metric', 0.000001))).toEqual(
`# TYPE prefix_metric gauge
prefix_metric 0.000001
`,
prefix_metric 0.000001`,
)
})
test('Renders simple metric with timestamp', () => {
expect(renderer.render([new Metric('metric', 0.000001, new Date('2000-01-01 00:00:00 UTC'))])).toEqual(
expect(renderer.render(new Metric('metric', 0.000001, new Date('2000-01-01 00:00:00 UTC')))).toEqual(
`# TYPE prefix_metric gauge
prefix_metric 0.000001 946684800000
`,
prefix_metric 0.000001 946684800000`,
)
})
test('Renders simple metric with labels', () => {
expect(
renderer.render([
renderer.render(
new Metric('metric', 0.000001, new Date('2000-01-01 00:00:00 UTC'), { label: 'Some Label' }),
]),
),
).toEqual(
`# TYPE prefix_metric gauge
prefix_metric{label="Some Label"} 0.000001 946684800000
`,
prefix_metric{label="Some Label"} 0.000001 946684800000`,
)
})
test('Renders total as counter', () => {
for (const metricName of ['some_total_metric', 'some_metric_total', 'total_some_metric']) {
expect(
renderer.render([
renderer.render(
new Metric(metricName, 42, new Date('2000-01-01 00:00:00 UTC'), { label: 'Some Label' }),
]),
),
).toEqual(
`# TYPE prefix_some_metric_total counter
prefix_some_metric_total{label="Some Label"} 42 946684800000
`,
prefix_some_metric_total{label="Some Label"} 42 946684800000`,
)
}
})
test('Renders multiple metrics correctly', () => {
expect(
renderer.render([
new Metric('some_gauge', 10, new Date('2000-01-01 00:00:00 UTC')),
new Metric('another_gauge', 30, new Date('2000-01-01 00:00:00 UTC')),
new Metric('some_gauge', 20, new Date('2000-01-01 00:00:00 UTC')),
]),
).toEqual(
`# TYPE prefix_some_gauge gauge
prefix_some_gauge 10 946684800000
prefix_some_gauge 20 946684800000
# TYPE prefix_another_gauge gauge
prefix_another_gauge 30 946684800000
`,
)
})
test('Sanitizes metric names', () => {
expect(renderer.render([new Metric('mätric name', 0)])).toEqual(
expect(renderer.render(new Metric('mätric name', 0))).toEqual(
`# TYPE prefix_m_tric_name gauge
prefix_m_tric_name 0
`,
prefix_m_tric_name 0`,
)
})
test('Sanitizes label names', () => {
expect(renderer.render([new Metric('metric', 0, null, { 'yet another label': 'foo' })])).toEqual(
expect(renderer.render(new Metric('metric', 0, null, { 'yet another label': 'foo' }))).toEqual(
`# TYPE prefix_metric gauge
prefix_metric{yet_another_label="foo"} 0
`,
prefix_metric{yet_another_label="foo"} 0`,
)
})
test('Escapes newlines in attribute value', () => {
expect(renderer.render([new Metric('metric', 0, null, { label: 'foo\nbar' })])).toEqual(
expect(renderer.render(new Metric('metric', 0, null, { label: 'foo\nbar' }))).toEqual(
`# TYPE prefix_metric gauge
prefix_metric{label="foo\\nbar"} 0
`,
prefix_metric{label="foo\\nbar"} 0`,
)
})
test('Escapes quotes in attribute value', () => {
expect(renderer.render([new Metric('metric', 0, null, { label: 'foo"bar' })])).toEqual(
expect(renderer.render(new Metric('metric', 0, null, { label: 'foo"bar' }))).toEqual(
`# TYPE prefix_metric gauge
prefix_metric{label="foo\\"bar"} 0
`,
prefix_metric{label="foo\\"bar"} 0`,
)
})
})

View file

@ -10,7 +10,7 @@
"rootDir": "./",
"strict": true,
"esModuleInterop": true,
"verbatimModuleSyntax": false,
"importsNotUsedAsValues": "error",
"noImplicitAny": true,
"resolveJsonModule": true,
"tsBuildInfoFile": ".tsbuildinfo",