UNPKG

autotel

Version:
1 lines 18.2 kB
{"version":3,"file":"slo.cjs","names":["getConfig","otelTrace"],"sources":["../src/slo.ts"],"sourcesContent":["/**\n * Service level objective tracking and burn-rate evaluation.\n *\n * The tracker keeps a bounded rolling window in memory, records low-cardinality\n * OpenTelemetry metrics, and returns snapshots that callers can feed into alert\n * evaluation. Production systems can persist the emitted metrics in their\n * telemetry backend while tests use the same calculations in process.\n */\n\nimport {\n trace as otelTrace,\n type Attributes,\n type Counter,\n type Histogram,\n} from '@opentelemetry/api';\nimport { getConfig } from './config';\n\nexport type SloOutcome = 'good' | 'bad';\n\nexport interface SloDefinition {\n /** Stable identifier used in metric attributes and alerts. */\n name: string;\n /** Required success ratio, expressed as a value between 0 and 1. */\n target: number;\n /** Rolling observation window in milliseconds. */\n windowMs: number;\n}\n\nexport interface SloTrackerOptions {\n /** Clock override for deterministic tests. */\n now?: () => number;\n /** Disable metric recording when calculations run outside an initialized SDK. */\n recordMetrics?: boolean;\n}\n\nexport interface SloSnapshot extends SloDefinition {\n observedAt: number;\n total: number;\n good: number;\n bad: number;\n /** Observed good-event ratio. Undefined until the tracker sees an event. */\n sli?: number;\n /** Fraction of events the objective permits to fail. */\n errorBudgetFraction: number;\n /** Fraction of the available error budget consumed in this window. */\n budgetConsumed: number;\n /** Remaining budget fraction. Negative values show overspend. */\n budgetRemaining: number;\n /** Observed failure ratio divided by the permitted failure ratio. */\n burnRate: number;\n meetsTarget: boolean;\n}\n\nexport interface SloTracker {\n readonly definition: Readonly<SloDefinition>;\n record(outcome: SloOutcome, attributes?: Attributes): SloSnapshot;\n snapshot(at?: number): SloSnapshot;\n forecast(options: SloForecastOptions): SloForecast;\n reset(): void;\n}\n\nexport interface SloForecastOptions {\n /** Recent observation period used to estimate the future failure rate. */\n baselineMs: number;\n /** Future period covered by the forecast. Must not exceed 4x the baseline. */\n lookaheadMs: number;\n /**\n * Expected eligible events during the lookahead. Defaults to the event rate\n * observed during the baseline.\n */\n expectedEventsInLookahead?: number;\n}\n\nexport interface SloForecast {\n name: string;\n target: number;\n observedAt: number;\n baselineMs: number;\n lookaheadMs: number;\n baselineTotal: number;\n baselineBad: number;\n baselineFailureRate: number;\n retainedTotal: number;\n retainedBad: number;\n projectedTotal: number;\n projectedBad: number;\n projectedSli?: number;\n /** Milliseconds until the retained and projected error budget is exhausted. */\n timeToExhaustionMs?: number;\n alerting: boolean;\n reason:\n 'no-baseline-traffic' | 'within-budget' | 'projected-budget-exhaustion';\n}\n\nexport interface BurnRateAlertOptions {\n shortWindow: SloSnapshot;\n longWindow: SloSnapshot;\n shortThreshold: number;\n longThreshold: number;\n}\n\nexport interface BurnRateAlertDecision {\n alerting: boolean;\n reason:\n | 'burn-rate-thresholds-exceeded'\n | 'short-window-below-threshold'\n | 'long-window-below-threshold'\n | 'no-traffic';\n shortBurnRate: number;\n longBurnRate: number;\n}\n\ninterface Observation {\n outcome: SloOutcome;\n timestamp: number;\n}\n\nfunction validateDefinition(definition: SloDefinition): void {\n if (!definition.name.trim()) {\n throw new Error('SLO name must not be empty');\n }\n if (\n !Number.isFinite(definition.target) ||\n definition.target <= 0 ||\n definition.target >= 1\n ) {\n throw new Error('SLO target must be greater than 0 and less than 1');\n }\n if (!Number.isFinite(definition.windowMs) || definition.windowMs <= 0) {\n throw new Error('SLO windowMs must be greater than 0');\n }\n}\n\nfunction validateThreshold(name: string, threshold: number): void {\n if (!Number.isFinite(threshold) || threshold <= 0) {\n throw new Error(`${name} must be greater than 0`);\n }\n}\n\nfunction validateForecastOptions(options: SloForecastOptions): void {\n if (!Number.isFinite(options.baselineMs) || options.baselineMs <= 0) {\n throw new Error('baselineMs must be greater than 0');\n }\n if (!Number.isFinite(options.lookaheadMs) || options.lookaheadMs <= 0) {\n throw new Error('lookaheadMs must be greater than 0');\n }\n if (options.lookaheadMs > options.baselineMs * 4) {\n throw new Error('lookaheadMs must not exceed 4 times baselineMs');\n }\n if (\n options.expectedEventsInLookahead !== undefined &&\n (!Number.isFinite(options.expectedEventsInLookahead) ||\n options.expectedEventsInLookahead < 0)\n ) {\n throw new Error('expectedEventsInLookahead must not be negative');\n }\n}\n\n/**\n * Track good and bad events for one rolling-window service level objective.\n */\nexport function createSloTracker(\n definition: SloDefinition,\n options: SloTrackerOptions = {},\n): SloTracker {\n validateDefinition(definition);\n\n const stableDefinition = Object.freeze({ ...definition });\n const now = options.now ?? Date.now;\n const observations: Observation[] = [];\n let firstLiveIndex = 0;\n\n let outcomeCounter: Counter | undefined;\n let burnRateHistogram: Histogram | undefined;\n\n if (options.recordMetrics !== false) {\n const meter = getConfig().meter;\n outcomeCounter = meter.createCounter('autotel.slo.outcomes', {\n description:\n 'Good and bad events recorded against a service level objective',\n unit: '1',\n });\n burnRateHistogram = meter.createHistogram('autotel.slo.burn_rate', {\n description: 'Error-budget burn rate for a service level objective',\n unit: '1',\n });\n }\n\n const prune = (at: number): void => {\n const cutoff = at - stableDefinition.windowMs;\n while (\n firstLiveIndex < observations.length &&\n observations[firstLiveIndex]!.timestamp < cutoff\n ) {\n firstLiveIndex += 1;\n }\n\n if (firstLiveIndex > 1024 && firstLiveIndex * 2 >= observations.length) {\n observations.splice(0, firstLiveIndex);\n firstLiveIndex = 0;\n }\n };\n\n const calculate = (at: number): SloSnapshot => {\n prune(at);\n\n let good = 0;\n for (let index = firstLiveIndex; index < observations.length; index += 1) {\n if (observations[index]!.outcome === 'good') good += 1;\n }\n\n const total = observations.length - firstLiveIndex;\n const bad = total - good;\n const sli = total === 0 ? undefined : good / total;\n const errorBudgetFraction = 1 - stableDefinition.target;\n const observedBadFraction = total === 0 ? 0 : bad / total;\n const burnRate = observedBadFraction / errorBudgetFraction;\n const budgetConsumed = burnRate;\n\n return {\n ...stableDefinition,\n observedAt: at,\n total,\n good,\n bad,\n sli,\n errorBudgetFraction,\n budgetConsumed,\n budgetRemaining: 1 - budgetConsumed,\n burnRate,\n meetsTarget: sli === undefined || sli >= stableDefinition.target,\n };\n };\n\n return {\n definition: stableDefinition,\n\n record(outcome: SloOutcome, attributes: Attributes = {}): SloSnapshot {\n if (outcome !== 'good' && outcome !== 'bad') {\n throw new Error('SLO outcome must be \"good\" or \"bad\"');\n }\n\n const timestamp = now();\n observations.push({ outcome, timestamp });\n const current = calculate(timestamp);\n const metricAttributes: Attributes = {\n ...attributes,\n 'slo.name': stableDefinition.name,\n 'slo.outcome': outcome,\n };\n\n outcomeCounter?.add(1, metricAttributes);\n burnRateHistogram?.record(current.burnRate, {\n 'slo.name': stableDefinition.name,\n });\n\n const activeSpan = otelTrace.getActiveSpan();\n activeSpan?.setAttributes({\n 'slo.name': stableDefinition.name,\n 'slo.target': stableDefinition.target,\n 'slo.outcome': outcome,\n 'slo.burn_rate': current.burnRate,\n });\n\n return current;\n },\n\n snapshot(at = now()): SloSnapshot {\n return calculate(at);\n },\n\n forecast(forecastOptions: SloForecastOptions): SloForecast {\n validateForecastOptions(forecastOptions);\n if (forecastOptions.baselineMs > stableDefinition.windowMs) {\n throw new Error('baselineMs must not exceed the SLO windowMs');\n }\n const observedAt = now();\n prune(observedAt);\n\n const baselineCutoff = observedAt - forecastOptions.baselineMs;\n const retainedCutoff =\n observedAt + forecastOptions.lookaheadMs - stableDefinition.windowMs;\n let baselineTotal = 0;\n let baselineBad = 0;\n let retainedTotal = 0;\n let retainedBad = 0;\n\n for (\n let index = firstLiveIndex;\n index < observations.length;\n index += 1\n ) {\n const observation = observations[index]!;\n if (observation.timestamp >= baselineCutoff) {\n baselineTotal += 1;\n if (observation.outcome === 'bad') baselineBad += 1;\n }\n if (observation.timestamp >= retainedCutoff) {\n retainedTotal += 1;\n if (observation.outcome === 'bad') retainedBad += 1;\n }\n }\n\n if (baselineTotal === 0) {\n return {\n name: stableDefinition.name,\n target: stableDefinition.target,\n observedAt,\n baselineMs: forecastOptions.baselineMs,\n lookaheadMs: forecastOptions.lookaheadMs,\n baselineTotal: 0,\n baselineBad: 0,\n baselineFailureRate: 0,\n retainedTotal,\n retainedBad,\n projectedTotal: retainedTotal,\n projectedBad: retainedBad,\n projectedSli:\n retainedTotal === 0\n ? undefined\n : (retainedTotal - retainedBad) / retainedTotal,\n alerting: false,\n reason: 'no-baseline-traffic',\n };\n }\n\n const baselineFailureRate = baselineBad / baselineTotal;\n const expectedEventsInLookahead =\n forecastOptions.expectedEventsInLookahead ??\n (baselineTotal * forecastOptions.lookaheadMs) /\n forecastOptions.baselineMs;\n const projectedFailures = baselineFailureRate * expectedEventsInLookahead;\n const projectedTotal = retainedTotal + expectedEventsInLookahead;\n const projectedBad = retainedBad + projectedFailures;\n const projectedSli =\n projectedTotal === 0\n ? undefined\n : (projectedTotal - projectedBad) / projectedTotal;\n const allowedFailureRate = 1 - stableDefinition.target;\n const remainingFailures =\n allowedFailureRate * retainedTotal - retainedBad;\n const netFailureRate = baselineFailureRate - allowedFailureRate;\n const eventRate = expectedEventsInLookahead / forecastOptions.lookaheadMs;\n const timeToExhaustionMs =\n remainingFailures <= 0\n ? 0\n : netFailureRate > 0 && eventRate > 0\n ? remainingFailures / (netFailureRate * eventRate)\n : undefined;\n const alerting =\n projectedSli !== undefined && projectedSli < stableDefinition.target;\n\n return {\n name: stableDefinition.name,\n target: stableDefinition.target,\n observedAt,\n baselineMs: forecastOptions.baselineMs,\n lookaheadMs: forecastOptions.lookaheadMs,\n baselineTotal,\n baselineBad,\n baselineFailureRate,\n retainedTotal,\n retainedBad,\n projectedTotal,\n projectedBad,\n projectedSli,\n timeToExhaustionMs,\n alerting,\n reason: alerting ? 'projected-budget-exhaustion' : 'within-budget',\n };\n },\n\n reset(): void {\n observations.length = 0;\n firstLiveIndex = 0;\n },\n };\n}\n\n/**\n * Evaluate a dual-window burn-rate alert.\n *\n * Both windows must exceed their threshold. The short window catches a sharp\n * regression while the long window rejects brief spikes.\n */\nexport function evaluateBurnRateAlert(\n options: BurnRateAlertOptions,\n): BurnRateAlertDecision {\n validateThreshold('shortThreshold', options.shortThreshold);\n validateThreshold('longThreshold', options.longThreshold);\n\n if (\n options.shortWindow.name !== options.longWindow.name ||\n options.shortWindow.target !== options.longWindow.target\n ) {\n throw new Error(\n 'Burn-rate windows must describe the same SLO name and target',\n );\n }\n\n if (options.shortWindow.total === 0 || options.longWindow.total === 0) {\n return {\n alerting: false,\n reason: 'no-traffic',\n shortBurnRate: options.shortWindow.burnRate,\n longBurnRate: options.longWindow.burnRate,\n };\n }\n\n if (options.shortWindow.burnRate < options.shortThreshold) {\n return {\n alerting: false,\n reason: 'short-window-below-threshold',\n shortBurnRate: options.shortWindow.burnRate,\n longBurnRate: options.longWindow.burnRate,\n };\n }\n\n if (options.longWindow.burnRate < options.longThreshold) {\n return {\n alerting: false,\n reason: 'long-window-below-threshold',\n shortBurnRate: options.shortWindow.burnRate,\n longBurnRate: options.longWindow.burnRate,\n };\n }\n\n return {\n alerting: true,\n reason: 'burn-rate-thresholds-exceeded',\n shortBurnRate: options.shortWindow.burnRate,\n longBurnRate: options.longWindow.burnRate,\n };\n}\n"],"mappings":"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"}