First public release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
.npmrc
|
||||||
|
dist-cjs/
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
stages: [test]
|
||||||
|
|
||||||
|
test:
|
||||||
|
stage: test
|
||||||
|
image: node:22-alpine
|
||||||
|
script: [npm ci, npm run build, npm test]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.3.0 - 2026-09-28
|
||||||
|
|
||||||
|
First public release under MIT.
|
||||||
|
|
||||||
|
- `slaReport()` computes availability over a half-open window, charges
|
||||||
|
downtime per failing sample capped at one probe interval, and excludes
|
||||||
|
scheduled maintenance.
|
||||||
|
- Zero samples gives `uptime_pct: null`, never 100. No target gives
|
||||||
|
`met: null`, never a pass or a failure.
|
||||||
|
- `toReport()` and `formatSla()` produce an `@extant2000/evidence-record`
|
||||||
|
report. Zero samples with a target is not assessed, not a missed SLA.
|
||||||
|
- `describeAvailability()` and `describeTarget()` put the null cases into
|
||||||
|
plain sentences.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
Issues and merge requests are welcome at
|
||||||
|
https://gitlab.com/extant2000/proof-of-up.
|
||||||
|
|
||||||
|
## Ground rules
|
||||||
|
|
||||||
|
- **A test that cannot fail proves nothing.** If you fix a bug, add a test
|
||||||
|
that fails without your fix, and check that it does fail before you submit.
|
||||||
|
- Measure, do not assume. Two modules with the same line count can be
|
||||||
|
different programs.
|
||||||
|
- Keep dependencies minimal. Every new dependency needs a reason.
|
||||||
|
- Explain why in comments, not what.
|
||||||
|
|
||||||
|
## Before you open a merge request
|
||||||
|
|
||||||
|
Run both of these and make sure they pass:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run build
|
||||||
|
npm test
|
||||||
|
```
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Extant 2000 LLC
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# ProofOfUp
|
||||||
|
|
||||||
|
Computes availability for uptime and SLA statements from probe samples, and
|
||||||
|
refuses to report a number the samples do not support. It is used in Hitt
|
||||||
|
Hosting products.
|
||||||
|
|
||||||
|
## What it does and why
|
||||||
|
|
||||||
|
An uptime figure is only as good as the samples behind it, and the common
|
||||||
|
failures all flatter the service. ProofOfUp computes availability over
|
||||||
|
samples you have already loaded and handles the edge cases in the direction
|
||||||
|
of saying less.
|
||||||
|
|
||||||
|
**Zero samples is not 100%.** With no samples, `uptime_pct` is `null`, and
|
||||||
|
the report says availability cannot be computed. A statement that answers
|
||||||
|
"100%" when nothing was measured is worse than one that abstains. It also
|
||||||
|
does not say the SLA was missed: when a target applies but nothing was
|
||||||
|
measured, the report is not assessed and states that the target was neither
|
||||||
|
met nor missed. (The raw `met` field stays `false` in that case.)
|
||||||
|
|
||||||
|
"No SLA target" is not "SLA met". With no target, `met` is `null` and the
|
||||||
|
report says no SLA target applies to this plan, so a free plan never gets a
|
||||||
|
statement that reads as a failure.
|
||||||
|
|
||||||
|
The rest of the arithmetic:
|
||||||
|
|
||||||
|
- the window is half-open, `[from, to)`, so adjacent monthly statements never
|
||||||
|
count a boundary sample twice;
|
||||||
|
- samples outside the window or with bad timestamps are dropped, not counted;
|
||||||
|
- each failing sample is charged the gap to the next sample, capped at one
|
||||||
|
probe interval (300 seconds by default), so a long reporting gap after one
|
||||||
|
failure cannot inflate downtime; the last sample is charged one interval;
|
||||||
|
- samples inside scheduled maintenance windows are removed from the math as if
|
||||||
|
the probe never ran; cancelled windows are ignored, overlapping windows are
|
||||||
|
merged, and windows are clipped to the period;
|
||||||
|
- uptime is rounded to one decimal, and the result does not depend on the
|
||||||
|
order of the input.
|
||||||
|
|
||||||
|
The functions are pure, with no I/O and no clock, so the same samples always
|
||||||
|
give the same numbers. That is what makes an availability figure evidence and
|
||||||
|
not a reading taken at a moment. Reports use the `@extant2000/evidence-record`
|
||||||
|
format. The availability citation carries its sample count, so 100% over zero
|
||||||
|
samples cannot be mistaken for 100% over thousands, and excluded maintenance
|
||||||
|
is reported as not applicable with its own measurement so planned downtime
|
||||||
|
stays visible.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { slaReport, formatSla, describeAvailability, describeTarget, type Check } from '@extant2000/proof-of-up'
|
||||||
|
|
||||||
|
// One probe result every five minutes, loaded from wherever you store them.
|
||||||
|
const checks: Check[] = [
|
||||||
|
{ ts: '2026-08-01T00:00:00Z', ok: true },
|
||||||
|
{ ts: '2026-08-01T00:05:00Z', ok: false },
|
||||||
|
// ...
|
||||||
|
]
|
||||||
|
|
||||||
|
const report = slaReport(checks, {
|
||||||
|
from: '2026-08-01T00:00:00Z',
|
||||||
|
to: '2026-09-01T00:00:00Z',
|
||||||
|
target: 99.9, // omit or null for no SLA
|
||||||
|
intervalSeconds: 300,
|
||||||
|
excludeWindows: [{ starts_at: '2026-08-10T02:00:00Z', ends_at: '2026-08-10T03:00:00Z' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
describeAvailability(report) // e.g. 'Availability 99.8% over 8916 samples, 100 minutes down.'
|
||||||
|
describeTarget(report) // e.g. 'Target 99.9% was NOT met.'
|
||||||
|
console.log(formatSla(report, { subject: 'app.example.com' }))
|
||||||
|
```
|
||||||
|
|
||||||
|
For a month of five-minute samples with 20 failures and one hour of
|
||||||
|
maintenance, `formatSla()` printed this (header line omitted):
|
||||||
|
|
||||||
|
```
|
||||||
|
NOT MET · 8916 items examined · 2 findings
|
||||||
|
Availability 99.8% over 8916 samples, 100 minutes down.
|
||||||
|
Target 99.9% was NOT met.
|
||||||
|
|
||||||
|
✗ CRITICAL Availability 99.8% over 8916 samples, 100 minutes down. [NOT MET]
|
||||||
|
Target 99.9% was NOT met.
|
||||||
|
evidence: Availability = 99.8% (8916 samples, 2026-08-01 → 2026-09-01)
|
||||||
|
◦ info 60 minutes of scheduled maintenance excluded (12 samples) [N/A]
|
||||||
|
```
|
||||||
|
|
||||||
|
With no samples, the same call reports NOT ASSESSED and "No samples in this
|
||||||
|
period; availability cannot be computed."
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```
|
||||||
|
npm install @extant2000/proof-of-up
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT. See [LICENSE](LICENSE).
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
Please report vulnerabilities privately by email to security@extant2000.com.
|
||||||
|
Do not open a public issue for a security problem.
|
||||||
|
|
||||||
|
We aim to acknowledge every report within 5 business days.
|
||||||
Generated
+1504
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "@extant2000/proof-of-up",
|
||||||
|
"version": "0.3.0",
|
||||||
|
"private": false,
|
||||||
|
"description": "Backs uptime claims with measured data.",
|
||||||
|
"license": "MIT",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist-cjs/index.js",
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"dist-cjs",
|
||||||
|
"src",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitlab.com/extant2000/proof-of-up.git"
|
||||||
|
},
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"require": "./dist-cjs/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc && tsc -p tsconfig.cjs.json && node -e \"require('fs').writeFileSync('dist-cjs/package.json', JSON.stringify({type:'commonjs'}))\"",
|
||||||
|
"test": "vitest run",
|
||||||
|
"prepublishOnly": "npm run build"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"vitest": "^2.1.0"
|
||||||
|
},
|
||||||
|
"module": "./dist/index.js",
|
||||||
|
"dependencies": {
|
||||||
|
"@extant2000/evidence-record": "^0.1.2"
|
||||||
|
},
|
||||||
|
"author": "Extant 2000 LLC",
|
||||||
|
"homepage": "https://gitlab.com/extant2000/proof-of-up",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://gitlab.com/extant2000/proof-of-up/-/issues"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './sla.js'
|
||||||
|
export { toReport, formatSla, describeAvailability, describeTarget } from './report.js'
|
||||||
|
export type { SlaReportOptions } from './report.js'
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
import {
|
||||||
|
type CapabilityReport,
|
||||||
|
type Finding,
|
||||||
|
type FormatOptions,
|
||||||
|
evidence,
|
||||||
|
formatReport as renderReport,
|
||||||
|
} from '@extant2000/evidence-record'
|
||||||
|
import type { SlaReport } from './sla.js'
|
||||||
|
|
||||||
|
export interface SlaReportOptions {
|
||||||
|
/** What the statement is about, e.g. "app.example.com". */
|
||||||
|
subject?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Say the null cases out loud.
|
||||||
|
*
|
||||||
|
* `uptime null, met=false` was correct and unreadable. Nobody should have to
|
||||||
|
* know that zero samples is not 100% in order to read the line, and nobody
|
||||||
|
* should have to know that a null target is not a missed target. The rigour
|
||||||
|
* was already in the arithmetic; this is the phrasing catching up.
|
||||||
|
*/
|
||||||
|
export function describeAvailability(r: SlaReport): string {
|
||||||
|
if (r.uptime_pct === null) {
|
||||||
|
return 'No samples in this period; availability cannot be computed.'
|
||||||
|
}
|
||||||
|
return `Availability ${r.uptime_pct}% over ${r.total_checks} samples, ${r.downtime_minutes} minutes down.`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeTarget(r: SlaReport): string {
|
||||||
|
if (r.target_pct === null) return 'No SLA target applies to this plan.'
|
||||||
|
if (r.uptime_pct === null) {
|
||||||
|
return `A ${r.target_pct}% target applies, but nothing was measured — the target was neither met nor missed.`
|
||||||
|
}
|
||||||
|
return r.met
|
||||||
|
? `Target ${r.target_pct}% was met.`
|
||||||
|
: `Target ${r.target_pct}% was NOT met.`
|
||||||
|
}
|
||||||
|
|
||||||
|
const window = (r: SlaReport) =>
|
||||||
|
r.from && r.to ? `${r.from.slice(0, 10)} → ${r.to.slice(0, 10)}` : 'unbounded period'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the portfolio-standard report for one availability statement.
|
||||||
|
*
|
||||||
|
* Zero samples with a target set produces `not-assessed`, not `fail`. The
|
||||||
|
* underlying `met: false` is preserved because the ported arithmetic is
|
||||||
|
* unchanged, but reporting a MISSED SLA when nothing was measured is an
|
||||||
|
* overclaim in the opposite direction from reporting 100% — and this library
|
||||||
|
* exists to avoid exactly that kind of unearned certainty.
|
||||||
|
*/
|
||||||
|
export function toReport(r: SlaReport, opts: SlaReportOptions = {}): CapabilityReport {
|
||||||
|
const subject = opts.subject ?? 'service'
|
||||||
|
const findings: Finding[] = []
|
||||||
|
|
||||||
|
const measured = evidence.measurement('Availability', r.uptime_pct, r.total_checks, {
|
||||||
|
unit: '%',
|
||||||
|
window: window(r),
|
||||||
|
})
|
||||||
|
|
||||||
|
findings.push({
|
||||||
|
id: 'availability',
|
||||||
|
summary: describeAvailability(r),
|
||||||
|
determination: r.uptime_pct === null
|
||||||
|
? 'not-assessed'
|
||||||
|
: r.target_pct === null
|
||||||
|
? 'not-applicable'
|
||||||
|
: r.met ? 'pass' : 'fail',
|
||||||
|
severity: r.uptime_pct === null ? 'high' : r.met === false ? 'critical' : 'info',
|
||||||
|
detail: describeTarget(r),
|
||||||
|
evidence: r.uptime_pct === null ? [] : [measured],
|
||||||
|
})
|
||||||
|
|
||||||
|
if (r.excluded_checks > 0) {
|
||||||
|
findings.push({
|
||||||
|
id: 'maintenance',
|
||||||
|
summary: `${r.excluded_minutes} minutes of scheduled maintenance excluded (${r.excluded_checks} samples)`,
|
||||||
|
determination: 'not-applicable',
|
||||||
|
severity: 'info',
|
||||||
|
detail: 'Planned downtime inside a declared window is removed from the availability math, as if the probe never ran. A cancelled window does not excuse anything.',
|
||||||
|
evidence: [evidence.measurement('Excluded maintenance', r.excluded_minutes, r.excluded_checks, {
|
||||||
|
unit: ' min',
|
||||||
|
window: window(r),
|
||||||
|
})],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
capability: 'ProofOfUp',
|
||||||
|
scope: `${subject}, ${window(r)}`,
|
||||||
|
// The sample count IS the examined count. Zero samples therefore forces
|
||||||
|
// not-assessed at the report level too, without a special case.
|
||||||
|
examined: r.total_checks,
|
||||||
|
findings,
|
||||||
|
// Both sentences go in the notes, not only on the finding: with zero
|
||||||
|
// samples the renderer leads with "nothing was assessed" and stops before
|
||||||
|
// the findings, and the reader still has to be told why.
|
||||||
|
notes: [describeAvailability(r), describeTarget(r)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render an availability statement for a terminal, CI log or customer PDF. */
|
||||||
|
export function formatSla(
|
||||||
|
r: SlaReport,
|
||||||
|
opts: SlaReportOptions & FormatOptions = {},
|
||||||
|
): string {
|
||||||
|
return renderReport(toReport(r, opts), opts)
|
||||||
|
}
|
||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* Availability math for uptime / SLA statements.
|
||||||
|
*
|
||||||
|
* Only the arithmetic lives here. Queries, auth, CSV and printable rendering
|
||||||
|
* stay in the product that calls it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Check {
|
||||||
|
/** Sample timestamp. */
|
||||||
|
ts: Date | string | number
|
||||||
|
/** Whether the probe succeeded. */
|
||||||
|
ok: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaintenanceWindow {
|
||||||
|
starts_at?: Date | string | number
|
||||||
|
ends_at?: Date | string | number
|
||||||
|
/** Aliases accepted for convenience. */
|
||||||
|
start?: Date | string | number
|
||||||
|
end?: Date | string | number
|
||||||
|
/** A `cancelled` window is ignored — it did not happen. */
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlaOptions {
|
||||||
|
from: Date | string | number
|
||||||
|
to: Date | string | number
|
||||||
|
/** Marketed availability target, e.g. 99.9. `null`/absent = no SLA. */
|
||||||
|
target?: number | null
|
||||||
|
/** Probe cadence in seconds. Bounds each failing sample's charged downtime. */
|
||||||
|
intervalSeconds?: number
|
||||||
|
excludeWindows?: MaintenanceWindow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SlaReport {
|
||||||
|
from: string | null
|
||||||
|
to: string | null
|
||||||
|
total_checks: number
|
||||||
|
up_checks: number
|
||||||
|
down_checks: number
|
||||||
|
/** `null` when there were no samples — NOT 100. */
|
||||||
|
uptime_pct: number | null
|
||||||
|
downtime_minutes: number
|
||||||
|
excluded_checks: number
|
||||||
|
excluded_minutes: number
|
||||||
|
target_pct: number | null
|
||||||
|
/** `null` when no target is set — "no SLA" is not "SLA met". */
|
||||||
|
met: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function ms(v: unknown): number {
|
||||||
|
if (v instanceof Date) return v.getTime()
|
||||||
|
return new Date(v as string).getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
function round1(n: number): number {
|
||||||
|
return Math.round(n * 10) / 10
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute an availability report over already-loaded probe samples.
|
||||||
|
*
|
||||||
|
* Pure: no I/O, no clock. Given the same samples it returns the same numbers,
|
||||||
|
* which is what makes an availability figure auditable evidence rather than a
|
||||||
|
* reading taken at a moment.
|
||||||
|
*
|
||||||
|
* The window is half-open `[from, to)` — includes `from`, excludes `to` — so
|
||||||
|
* adjacent monthly statements never double-count a boundary sample.
|
||||||
|
*/
|
||||||
|
export function slaReport(checks: Check[], opts: SlaOptions): SlaReport {
|
||||||
|
const fromMs = ms(opts.from)
|
||||||
|
const toMs = ms(opts.to)
|
||||||
|
|
||||||
|
const target = opts.target != null && Number.isFinite(Number(opts.target))
|
||||||
|
? Number(opts.target)
|
||||||
|
: null
|
||||||
|
|
||||||
|
const intervalSec = Number.isFinite(Number(opts.intervalSeconds)) && Number(opts.intervalSeconds) > 0
|
||||||
|
? Number(opts.intervalSeconds)
|
||||||
|
: 300
|
||||||
|
const intervalMs = intervalSec * 1000
|
||||||
|
|
||||||
|
const fromIso = Number.isFinite(fromMs) ? new Date(fromMs).toISOString() : null
|
||||||
|
const toIso = Number.isFinite(toMs) ? new Date(toMs).toISOString() : null
|
||||||
|
|
||||||
|
// ── Scheduled-maintenance exclusion ──────────────────────────────────────
|
||||||
|
// Normalize windows into half-open [s, e) intervals clipped to the report
|
||||||
|
// period, dropping cancelled ones. A sample inside such an interval is
|
||||||
|
// removed from the availability math entirely — as if the probe never ran —
|
||||||
|
// so planned downtime never counts against uptime_pct.
|
||||||
|
const raw: Array<[number, number]> = []
|
||||||
|
if (Array.isArray(opts.excludeWindows) && Number.isFinite(fromMs) && Number.isFinite(toMs)) {
|
||||||
|
for (const w of opts.excludeWindows) {
|
||||||
|
if (!w || w.status === 'cancelled') continue
|
||||||
|
const s = ms(w.starts_at ?? w.start)
|
||||||
|
const e = ms(w.ends_at ?? w.end)
|
||||||
|
if (!Number.isFinite(s) || !Number.isFinite(e) || e <= s) continue
|
||||||
|
const cs = Math.max(s, fromMs)
|
||||||
|
const ce = Math.min(e, toMs)
|
||||||
|
if (ce > cs) raw.push([cs, ce])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge overlapping/adjacent intervals so excluded_minutes never
|
||||||
|
// double-counts two windows that overlap.
|
||||||
|
raw.sort((a, b) => a[0] - b[0])
|
||||||
|
const merged: Array<[number, number]> = []
|
||||||
|
for (const iv of raw) {
|
||||||
|
const last = merged[merged.length - 1]
|
||||||
|
if (last && iv[0] <= last[1]) last[1] = Math.max(last[1], iv[1])
|
||||||
|
else merged.push([iv[0], iv[1]])
|
||||||
|
}
|
||||||
|
const isExcluded = (t: number) => merged.some(([s, e]) => t >= s && t < e)
|
||||||
|
const excludedMs = merged.reduce((acc, [s, e]) => acc + (e - s), 0)
|
||||||
|
|
||||||
|
// ── Collect in-window samples ────────────────────────────────────────────
|
||||||
|
const kept: Array<{ t: number, ok: boolean }> = []
|
||||||
|
let excludedChecks = 0
|
||||||
|
if (Array.isArray(checks) && Number.isFinite(fromMs) && Number.isFinite(toMs)) {
|
||||||
|
for (const c of checks) {
|
||||||
|
if (!c || c.ts == null) continue
|
||||||
|
const t = ms(c.ts)
|
||||||
|
if (!Number.isFinite(t) || t < fromMs || t >= toMs) continue
|
||||||
|
if (isExcluded(t)) { excludedChecks++; continue }
|
||||||
|
kept.push({ t, ok: !!c.ok })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
kept.sort((a, b) => a.t - b.t)
|
||||||
|
|
||||||
|
// ── Availability ─────────────────────────────────────────────────────────
|
||||||
|
let up = 0
|
||||||
|
let downtimeMs = 0
|
||||||
|
for (let i = 0; i < kept.length; i++) {
|
||||||
|
const cur = kept[i]!
|
||||||
|
if (cur.ok) { up++; continue }
|
||||||
|
// Charge the gap to the next sample, capped at one probe interval, so a
|
||||||
|
// long reporting gap after a single failure cannot inflate downtime. The
|
||||||
|
// last sample has no successor and is charged exactly one interval.
|
||||||
|
const next = kept[i + 1]
|
||||||
|
const gap = next ? next.t - cur.t : intervalMs
|
||||||
|
downtimeMs += Math.min(Math.max(gap, 0), intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = kept.length
|
||||||
|
// null, not 100: zero samples is no evidence, and an availability report that
|
||||||
|
// answers "100%" when nothing was measured is worse than one that abstains.
|
||||||
|
const pct = total === 0 ? null : round1((up / total) * 100)
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: fromIso,
|
||||||
|
to: toIso,
|
||||||
|
total_checks: total,
|
||||||
|
up_checks: up,
|
||||||
|
down_checks: total - up,
|
||||||
|
uptime_pct: pct,
|
||||||
|
downtime_minutes: round1(downtimeMs / 60000),
|
||||||
|
excluded_checks: excludedChecks,
|
||||||
|
excluded_minutes: round1(excludedMs / 60000),
|
||||||
|
target_pct: target,
|
||||||
|
// null, not false: "no SLA target" and "SLA missed" are different claims,
|
||||||
|
// and a free plan must not produce a statement that reads as a failure.
|
||||||
|
met: target == null ? null : (pct != null && pct >= target),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { slaReport, toReport, formatSla, type Check } from '../src/index.js'
|
||||||
|
import { overall, validateReport } from '@extant2000/evidence-record'
|
||||||
|
|
||||||
|
const FROM = '2026-07-01T00:00:00.000Z'
|
||||||
|
const TO = '2026-08-01T00:00:00.000Z'
|
||||||
|
|
||||||
|
/** n samples 5 minutes apart from `start`, `okAt` decides pass/fail. */
|
||||||
|
function series(start: string, n: number, okAt: (i: number) => boolean): Check[] {
|
||||||
|
const t0 = new Date(start).getTime()
|
||||||
|
return Array.from({ length: n }, (_, i) => ({
|
||||||
|
ts: new Date(t0 + i * 300_000).toISOString(),
|
||||||
|
ok: okAt(i),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('slaReport — refusing flattering answers', () => {
|
||||||
|
it('reports uptime_pct as NULL with no samples, not 100', () => {
|
||||||
|
// An availability report that answers "100%" when nothing was measured is
|
||||||
|
// worse than one that abstains.
|
||||||
|
const r = slaReport([], { from: FROM, to: TO, target: 99.9 })
|
||||||
|
expect(r.uptime_pct).toBeNull()
|
||||||
|
expect(r.total_checks).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports met as NULL with no target, not false', () => {
|
||||||
|
// "no SLA target" and "SLA missed" are different claims. A free plan must
|
||||||
|
// not produce a statement that reads as a failure.
|
||||||
|
expect(slaReport(series(FROM, 10, () => true), { from: FROM, to: TO }).met).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cannot report met=true from zero evidence', () => {
|
||||||
|
const r = slaReport([], { from: FROM, to: TO, target: 99.9 })
|
||||||
|
expect(r.met).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('slaReport — window semantics', () => {
|
||||||
|
it('is half-open [from, to) so adjacent months never double-count', () => {
|
||||||
|
const boundary: Check[] = [
|
||||||
|
{ ts: FROM, ok: true }, // included
|
||||||
|
{ ts: TO, ok: true }, // excluded — belongs to the NEXT statement
|
||||||
|
]
|
||||||
|
expect(slaReport(boundary, { from: FROM, to: TO }).total_checks).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops samples outside the window entirely', () => {
|
||||||
|
const r = slaReport([
|
||||||
|
{ ts: '2026-06-30T23:59:59.000Z', ok: false },
|
||||||
|
{ ts: '2026-07-15T00:00:00.000Z', ok: true },
|
||||||
|
], { from: FROM, to: TO })
|
||||||
|
expect(r.total_checks).toBe(1)
|
||||||
|
expect(r.uptime_pct).toBe(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores malformed samples rather than counting them', () => {
|
||||||
|
const r = slaReport(
|
||||||
|
[{ ts: 'not-a-date', ok: false }, { ts: FROM, ok: true }] as Check[],
|
||||||
|
{ from: FROM, to: TO },
|
||||||
|
)
|
||||||
|
expect(r.total_checks).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('slaReport — availability and downtime', () => {
|
||||||
|
it('computes uptime to one decimal', () => {
|
||||||
|
// 999 up, 1 down out of 1000 → 99.9
|
||||||
|
const r = slaReport(series(FROM, 1000, i => i !== 500), { from: FROM, to: TO, target: 99.9 })
|
||||||
|
expect(r.uptime_pct).toBe(99.9)
|
||||||
|
expect(r.up_checks).toBe(999)
|
||||||
|
expect(r.down_checks).toBe(1)
|
||||||
|
expect(r.met).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('misses the target just below it', () => {
|
||||||
|
// 998/1000 = 99.8 < 99.9
|
||||||
|
const r = slaReport(series(FROM, 1000, i => i !== 500 && i !== 600), {
|
||||||
|
from: FROM, to: TO, target: 99.9,
|
||||||
|
})
|
||||||
|
expect(r.uptime_pct).toBe(99.8)
|
||||||
|
expect(r.met).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charges a failing sample the gap to its successor, capped at one interval', () => {
|
||||||
|
// Two failures 5 min apart at a 300s cadence → 5 min each = 10 min.
|
||||||
|
const r = slaReport(series(FROM, 3, i => i === 2), {
|
||||||
|
from: FROM, to: TO, intervalSeconds: 300,
|
||||||
|
})
|
||||||
|
expect(r.downtime_minutes).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('caps a long reporting gap at one interval', () => {
|
||||||
|
// A single failure followed by a sample a DAY later must not charge a day.
|
||||||
|
const r = slaReport([
|
||||||
|
{ ts: '2026-07-01T00:00:00.000Z', ok: false },
|
||||||
|
{ ts: '2026-07-02T00:00:00.000Z', ok: true },
|
||||||
|
], { from: FROM, to: TO, intervalSeconds: 300 })
|
||||||
|
expect(r.downtime_minutes).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('charges the final sample exactly one interval when it has no successor', () => {
|
||||||
|
const r = slaReport([{ ts: FROM, ok: false }], {
|
||||||
|
from: FROM, to: TO, intervalSeconds: 600,
|
||||||
|
})
|
||||||
|
expect(r.downtime_minutes).toBe(10)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('slaReport — scheduled maintenance', () => {
|
||||||
|
const win = [{ starts_at: '2026-07-10T00:00:00.000Z', ends_at: '2026-07-10T01:00:00.000Z' }]
|
||||||
|
|
||||||
|
it('removes samples inside a window from the math entirely', () => {
|
||||||
|
const r = slaReport([
|
||||||
|
{ ts: '2026-07-10T00:30:00.000Z', ok: false }, // planned downtime
|
||||||
|
{ ts: '2026-07-11T00:00:00.000Z', ok: true },
|
||||||
|
], { from: FROM, to: TO, excludeWindows: win, target: 99.9 })
|
||||||
|
|
||||||
|
expect(r.excluded_checks).toBe(1)
|
||||||
|
expect(r.total_checks).toBe(1)
|
||||||
|
expect(r.uptime_pct).toBe(100) // planned downtime never counts against it
|
||||||
|
expect(r.excluded_minutes).toBe(60)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores a cancelled window — it did not happen', () => {
|
||||||
|
const r = slaReport([{ ts: '2026-07-10T00:30:00.000Z', ok: false }], {
|
||||||
|
from: FROM, to: TO,
|
||||||
|
excludeWindows: [{ ...win[0]!, status: 'cancelled' }],
|
||||||
|
})
|
||||||
|
expect(r.excluded_checks).toBe(0)
|
||||||
|
expect(r.uptime_pct).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does NOT double-count overlapping windows', () => {
|
||||||
|
const r = slaReport([], {
|
||||||
|
from: FROM, to: TO,
|
||||||
|
excludeWindows: [
|
||||||
|
{ starts_at: '2026-07-10T00:00:00.000Z', ends_at: '2026-07-10T02:00:00.000Z' },
|
||||||
|
{ starts_at: '2026-07-10T01:00:00.000Z', ends_at: '2026-07-10T03:00:00.000Z' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(r.excluded_minutes).toBe(180) // union, not 120 + 120
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clips a window to the report period', () => {
|
||||||
|
const r = slaReport([], {
|
||||||
|
from: FROM, to: TO,
|
||||||
|
excludeWindows: [{ starts_at: '2026-06-01T00:00:00.000Z', ends_at: '2026-07-01T01:00:00.000Z' }],
|
||||||
|
})
|
||||||
|
expect(r.excluded_minutes).toBe(60) // only the hour inside July
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops a zero-length or inverted window', () => {
|
||||||
|
const r = slaReport([], {
|
||||||
|
from: FROM, to: TO,
|
||||||
|
excludeWindows: [
|
||||||
|
{ starts_at: '2026-07-10T01:00:00.000Z', ends_at: '2026-07-10T01:00:00.000Z' },
|
||||||
|
{ starts_at: '2026-07-10T05:00:00.000Z', ends_at: '2026-07-10T04:00:00.000Z' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(r.excluded_minutes).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts start/end aliases', () => {
|
||||||
|
const r = slaReport([], {
|
||||||
|
from: FROM, to: TO,
|
||||||
|
excludeWindows: [{ start: '2026-07-10T00:00:00.000Z', end: '2026-07-10T00:30:00.000Z' }],
|
||||||
|
})
|
||||||
|
expect(r.excluded_minutes).toBe(30)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('slaReport — reproducibility', () => {
|
||||||
|
it('same input, identical output', () => {
|
||||||
|
const checks = series(FROM, 200, i => i % 17 !== 0)
|
||||||
|
const a = slaReport(checks, { from: FROM, to: TO, target: 99 })
|
||||||
|
const b = slaReport(checks, { from: FROM, to: TO, target: 99 })
|
||||||
|
expect(a).toEqual(b)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is order-independent — unsorted input gives the same answer', () => {
|
||||||
|
const checks = series(FROM, 50, i => i !== 10)
|
||||||
|
const shuffled = [...checks].reverse()
|
||||||
|
expect(slaReport(shuffled, { from: FROM, to: TO, intervalSeconds: 300 }))
|
||||||
|
.toEqual(slaReport(checks, { from: FROM, to: TO, intervalSeconds: 300 }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('report — the null cases said out loud', () => {
|
||||||
|
const period = { from: '2026-07-01T00:00:00Z', to: '2026-08-01T00:00:00Z' }
|
||||||
|
|
||||||
|
it('reads zero samples as uncomputable, not as a missed SLA', () => {
|
||||||
|
// `uptime null, met=false` is correct arithmetic and an overclaim to
|
||||||
|
// print: nothing was measured, so nothing was missed.
|
||||||
|
const r = slaReport([], { ...period, target: 99.9 })
|
||||||
|
expect(r.uptime_pct).toBeNull()
|
||||||
|
expect(r.met).toBe(false) // ported behaviour, unchanged
|
||||||
|
expect(overall(toReport(r))).toBe('not-assessed') // what a reader is told
|
||||||
|
const text = formatSla(r)
|
||||||
|
expect(text).toContain('No samples in this period; availability cannot be computed.')
|
||||||
|
expect(text).toContain('neither met nor missed')
|
||||||
|
expect(text).not.toContain('100%')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says plainly when no SLA target applies', () => {
|
||||||
|
const r = slaReport([{ ts: '2026-07-02T00:00:00Z', ok: true }], period)
|
||||||
|
expect(r.met).toBeNull()
|
||||||
|
expect(formatSla(r)).toContain('No SLA target applies to this plan.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('carries the sample count into the citation', () => {
|
||||||
|
const checks = Array.from({ length: 100 }, (_, i) => ({
|
||||||
|
ts: new Date(Date.parse(period.from) + i * 300_000).toISOString(),
|
||||||
|
ok: i !== 3,
|
||||||
|
}))
|
||||||
|
const r = slaReport(checks, { ...period, target: 99.9 })
|
||||||
|
const text = formatSla(r, { subject: 'app.example.com', evidence: 'inline' })
|
||||||
|
expect(text).toContain('100 samples')
|
||||||
|
expect(text).toContain('app.example.com')
|
||||||
|
expect(overall(toReport(r))).toBe('fail')
|
||||||
|
expect(text).toContain('was NOT met')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports excluded maintenance as not-applicable, with its own measurement', () => {
|
||||||
|
const checks = Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
ts: new Date(Date.parse(period.from) + i * 300_000).toISOString(),
|
||||||
|
ok: true,
|
||||||
|
}))
|
||||||
|
const r = slaReport(checks, {
|
||||||
|
...period,
|
||||||
|
target: 99.9,
|
||||||
|
excludeWindows: [{ start: period.from, end: new Date(Date.parse(period.from) + 3600_000).toISOString() }],
|
||||||
|
})
|
||||||
|
expect(r.excluded_checks).toBeGreaterThan(0)
|
||||||
|
const f = toReport(r).findings.find(x => x.id === 'maintenance')!
|
||||||
|
expect(f.determination).toBe('not-applicable')
|
||||||
|
expect(formatSla(r)).toContain('scheduled maintenance excluded')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every conclusion carries a citation', () => {
|
||||||
|
const r = slaReport([{ ts: '2026-07-02T00:00:00Z', ok: true }], { ...period, target: 99.9 })
|
||||||
|
expect(validateReport(toReport(r))).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "Node",
|
||||||
|
"outDir": "dist-cjs",
|
||||||
|
"declaration": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022", "module": "ES2022", "moduleResolution": "bundler",
|
||||||
|
"declaration": true, "outDir": "dist", "rootDir": "src",
|
||||||
|
"strict": true, "skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user