Initial Commit
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
export type {
|
||||
RateLimitCounterName,
|
||||
RateLimitInstruments,
|
||||
RateLimitInstrumentsConfig,
|
||||
} from "./instruments.js";
|
||||
export {
|
||||
createRateLimitInstruments,
|
||||
RATE_LIMIT_COUNTER_NAMES,
|
||||
} from "./instruments.js";
|
||||
@@ -0,0 +1,73 @@
|
||||
# instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Owns the rate-limit OTel instrument registry. Today the package emits
|
||||
exactly one counter — `api.rate_limit.exceeded_total{plugin, route,
|
||||
key_kind}` — which the design doc §12.4 lists as the canonical
|
||||
rate-limit denial signal. The factory wraps the OTel global
|
||||
meter-provider lookup so production gets real export when an SDK is
|
||||
installed and tests get a no-op when one is not.
|
||||
|
||||
## Features
|
||||
|
||||
- `RATE_LIMIT_COUNTER_NAMES` frozen tuple — drives both factory
|
||||
construction AND the conformance test that asserts each name is
|
||||
present on the returned bundle.
|
||||
- `createRateLimitInstruments({ meterProvider? })` — explicit override
|
||||
precedence beats the global, so the middleware tests can assert
|
||||
exact `.add` call counts against an injected fake provider.
|
||||
- Returned object is `Object.freeze`-d so accidental mutation surfaces
|
||||
immediately rather than silently breaking downstream label
|
||||
invariants.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createRateLimitInstruments } from "@suaveplan/api-rate-limit/instruments";
|
||||
|
||||
const instruments = createRateLimitInstruments();
|
||||
instruments.counters["api.rate_limit.exceeded_total"].add(1, {
|
||||
plugin: "billing",
|
||||
route: "POST /invoices",
|
||||
key_kind: "principal",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire a captured meter-provider to assert exactly-once denial
|
||||
increments under a deterministic clock:
|
||||
|
||||
```ts
|
||||
import type { MeterProvider } from "@opentelemetry/api";
|
||||
import { createRateLimitInstruments } from "@suaveplan/api-rate-limit/instruments";
|
||||
|
||||
declare const fakeProvider: MeterProvider; // captures every .add() call
|
||||
const instruments = createRateLimitInstruments({ meterProvider: fakeProvider });
|
||||
// drive the middleware against the fake clock, then assert
|
||||
// fakeProvider's recorded .add() invocations match the denial transitions
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `const RATE_LIMIT_COUNTER_NAMES: readonly ["api.rate_limit.exceeded_total"]`
|
||||
- `type RateLimitCounterName = (typeof RATE_LIMIT_COUNTER_NAMES)[number]`
|
||||
- `interface RateLimitInstruments { counters: Readonly<Record<RateLimitCounterName, Counter>> }`
|
||||
- `interface RateLimitInstrumentsConfig { meterProvider?: MeterProvider }`
|
||||
- `function createRateLimitInstruments(config?: RateLimitInstrumentsConfig): RateLimitInstruments`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The package deliberately does NOT install an SDK — that's an operator
|
||||
concern. When no SDK is registered, the OTel no-op meter-provider
|
||||
returns no-op counters whose `.add()` is a function-call no-op, so
|
||||
this module is hot-path safe even with default config. The host's
|
||||
own `HostInstruments` already declares the same counter name; both
|
||||
emit, both reach the same exporter — OTel aggregates correctly because
|
||||
the meter name is the only difference and the SDK merges series by
|
||||
name + label-set.
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Meter, MeterProvider } from "@opentelemetry/api";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
createRateLimitInstruments,
|
||||
RATE_LIMIT_COUNTER_NAMES,
|
||||
} from "./instruments.js";
|
||||
|
||||
interface RecordedCounter {
|
||||
readonly name: string;
|
||||
readonly addCalls: {
|
||||
value: number;
|
||||
attributes?: Readonly<Record<string, unknown>>;
|
||||
}[];
|
||||
}
|
||||
|
||||
function fakeProvider(): {
|
||||
provider: MeterProvider;
|
||||
counters: RecordedCounter[];
|
||||
} {
|
||||
const counters: RecordedCounter[] = [];
|
||||
const provider: MeterProvider = {
|
||||
getMeter(_name: string): Meter {
|
||||
const meter: Meter = {
|
||||
createCounter(n: string) {
|
||||
const r: RecordedCounter = { name: n, addCalls: [] };
|
||||
counters.push(r);
|
||||
return {
|
||||
add(
|
||||
v: number,
|
||||
attrs?: Readonly<Record<string, unknown>>
|
||||
) {
|
||||
r.addCalls.push({ value: v, attributes: attrs });
|
||||
},
|
||||
} as ReturnType<Meter["createCounter"]>;
|
||||
},
|
||||
createUpDownCounter() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
createHistogram() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
createObservableCounter() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
createObservableGauge() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
createObservableUpDownCounter() {
|
||||
throw new Error("not used");
|
||||
},
|
||||
addBatchObservableCallback() {
|
||||
/* noop */
|
||||
},
|
||||
removeBatchObservableCallback() {
|
||||
/* noop */
|
||||
},
|
||||
} as Meter;
|
||||
return meter;
|
||||
},
|
||||
};
|
||||
return { provider, counters };
|
||||
}
|
||||
|
||||
describe("instruments", () => {
|
||||
it("creates the rate-limit exceeded counter", { timeout: 5000 }, () => {
|
||||
const { provider, counters } = fakeProvider();
|
||||
const inst = createRateLimitInstruments({ meterProvider: provider });
|
||||
expect(counters.map((c) => c.name)).toEqual([
|
||||
"api.rate_limit.exceeded_total",
|
||||
]);
|
||||
inst.counters["api.rate_limit.exceeded_total"].add(1, {
|
||||
plugin: "p",
|
||||
route: "GET /",
|
||||
key_kind: "principal",
|
||||
});
|
||||
expect(counters[0]!.addCalls).toHaveLength(1);
|
||||
expect(counters[0]!.addCalls[0]!.attributes).toEqual({
|
||||
plugin: "p",
|
||||
route: "GET /",
|
||||
key_kind: "principal",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to global meter-provider when none supplied", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const inst = createRateLimitInstruments();
|
||||
// Global no-op provider returns no-op counters; .add must not throw.
|
||||
expect(() =>
|
||||
inst.counters["api.rate_limit.exceeded_total"].add(1, {})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("exposes the full counter-name tuple", { timeout: 5000 }, () => {
|
||||
expect(RATE_LIMIT_COUNTER_NAMES).toEqual([
|
||||
"api.rate_limit.exceeded_total",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { MeterProvider, Counter as OtelCounter } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Counter names emitted by this package on production paths. Mirrors
|
||||
* the design-doc §12.4 entry exactly so the host's metrics inventory
|
||||
* stays a single source of truth.
|
||||
*/
|
||||
export const RATE_LIMIT_COUNTER_NAMES = [
|
||||
"api.rate_limit.exceeded_total",
|
||||
] as const;
|
||||
export type RateLimitCounterName = (typeof RATE_LIMIT_COUNTER_NAMES)[number];
|
||||
|
||||
/** Frozen instrument bundle the middleware records denials against. */
|
||||
export interface RateLimitInstruments {
|
||||
readonly counters: Readonly<Record<RateLimitCounterName, OtelCounter>>;
|
||||
}
|
||||
|
||||
/** Configuration for {@link createRateLimitInstruments}. */
|
||||
export interface RateLimitInstrumentsConfig {
|
||||
/** Optional OTel meter-provider override; defaults to the global one. */
|
||||
readonly meterProvider?: MeterProvider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the rate-limit instrument registry. The meter is named
|
||||
* `@suaveplan/api-rate-limit` so exported metrics are unambiguous in
|
||||
* dashboards. When no SDK is installed the OTel no-op provider
|
||||
* silently swallows the `.add()` calls.
|
||||
*/
|
||||
export function createRateLimitInstruments(
|
||||
config: RateLimitInstrumentsConfig = {}
|
||||
): RateLimitInstruments {
|
||||
const provider = config.meterProvider ?? metrics.getMeterProvider();
|
||||
const meter = provider.getMeter("@suaveplan/api-rate-limit");
|
||||
const counters = Object.create(null) as Record<
|
||||
RateLimitCounterName,
|
||||
OtelCounter
|
||||
>;
|
||||
for (const name of RATE_LIMIT_COUNTER_NAMES) {
|
||||
counters[name] = meter.createCounter(name);
|
||||
}
|
||||
return Object.freeze({ counters: Object.freeze(counters) });
|
||||
}
|
||||
Reference in New Issue
Block a user