Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
export {
getNotificationsInstruments,
type NotificationsInstruments,
resetNotificationsInstruments,
} from "./instruments.js";

View File

@@ -0,0 +1,85 @@
# `otel/instruments`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
OpenTelemetry instrument bundle for `@suaveplan/api-plugin-notifications`.
The module exposes a lazy, memoised factory that resolves a meter
against the current global `MeterProvider` on first call and caches the
resulting `Counter` instances for the lifetime of the process. A
companion `resetNotificationsInstruments()` clears the cache so tests
can install a fresh in-memory `MeterProvider` and re-resolve the meter
against it deterministically.
## Features
- Six counters covering the entire production surface: notification
create / read / delete, push send / fail, and device-token registry.
- Single meter per process — the meter is created with the package
name, so traces grouped by `service.namespace` keep notifications
metrics separable from sibling plugins.
- Reset helper for tests — the test suite installs a fresh
`InMemoryMetricExporter` per file, resets the cache, and reads back
cumulative metrics through `flushTestMetrics()` from
`tests/setup/polyfills.ts`.
- Counter attributes are caller-supplied. The route layer passes
`{ platform: "ios" }` to `pushSentTotal.add(1, attrs)` and
`{ reason: "BadDeviceToken" }` to `pushFailedTotal.add(1, attrs)`.
## Usage: Basic
```ts
import { getNotificationsInstruments } from "@suaveplan/api-plugin-notifications";
const inst = getNotificationsInstruments();
inst.notificationsCreatedTotal.add(1);
inst.pushSentTotal.add(1, { platform: "ios" });
```
## Usage: Advanced
For test code that installs a fresh provider, reset the cache so the
next call rebinds:
```ts
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
} from "@opentelemetry/sdk-metrics";
import {
getNotificationsInstruments,
resetNotificationsInstruments,
} from "@suaveplan/api-plugin-notifications";
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const provider = new MeterProvider();
metrics.setGlobalMeterProvider(provider);
resetNotificationsInstruments();
const inst = getNotificationsInstruments();
inst.pushFailedTotal.add(1, { reason: "BadDeviceToken" });
```
## API Reference
- `NotificationsInstruments` — readonly interface with six `Counter`
fields.
- `getNotificationsInstruments(): NotificationsInstruments` — lazy
memoised factory. Idempotent within a session.
- `resetNotificationsInstruments(): void` — drop the cache so the next
factory call rebinds against the current global provider.
## Implementation Notes
The lazy-bind pattern is required because the meter resolution copies
the `MeterProvider` reference at meter-create time. If the bundle were
created at module load, every test in the suite would share one meter
bound to whatever provider happened to be installed first, defeating
isolation. The cache resets between tests via the
`afterEach(resetNotificationsInstruments)` hook in
`instruments.test.ts`.

View File

@@ -0,0 +1,90 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import {
getNotificationsInstruments,
resetNotificationsInstruments,
} from "./instruments.js";
afterEach(() => {
resetNotificationsInstruments();
});
describe("getNotificationsInstruments", () => {
it("returns the same bundle on repeated calls", { timeout: 5000 }, () => {
resetNotificationsInstruments();
const a = getNotificationsInstruments();
const b = getNotificationsInstruments();
expect(a).toBe(b);
});
it("exposes six named counters", { timeout: 5000 }, () => {
resetNotificationsInstruments();
const inst = getNotificationsInstruments();
expect(inst.notificationsCreatedTotal).toBeDefined();
expect(inst.notificationsReadTotal).toBeDefined();
expect(inst.notificationsDeletedTotal).toBeDefined();
expect(inst.pushSentTotal).toBeDefined();
expect(inst.pushFailedTotal).toBeDefined();
expect(inst.deviceTokensRegisteredTotal).toBeDefined();
});
it("exposes dispatcher counters", { timeout: 5000 }, () => {
resetNotificationsInstruments();
const inst = getNotificationsInstruments();
expect(inst.notificationsDispatchedTotal).toBeDefined();
expect(inst.notificationsDispatchFailedTotal).toBeDefined();
});
it("dispatcher counters emit on flush", { timeout: 30000 }, async () => {
resetNotificationsInstruments();
const inst = getNotificationsInstruments();
inst.notificationsDispatchedTotal.add(1, { channel: "apn" });
inst.notificationsDispatchFailedTotal.add(1, {
channel: "email",
reason: "transport_error",
});
const snapshots = await flushTestMetrics();
const names = snapshots.flatMap((s) =>
s.scopeMetrics.flatMap((sm) =>
sm.metrics.map((m) => m.descriptor.name)
)
);
expect(names).toContain("notifications.dispatched_total");
expect(names).toContain("notifications.dispatch_failed_total");
});
it("emits notification metric names on flush", {
timeout: 30000,
}, async () => {
resetNotificationsInstruments();
const inst = getNotificationsInstruments();
inst.notificationsCreatedTotal.add(1, { type: "friend_request" });
inst.notificationsReadTotal.add(2);
inst.notificationsDeletedTotal.add(1);
inst.pushSentTotal.add(3, { platform: "ios" });
inst.pushFailedTotal.add(1, { reason: "BadDeviceToken" });
inst.deviceTokensRegisteredTotal.add(1, { platform: "ios" });
const snapshots = await flushTestMetrics();
const names = snapshots.flatMap((s) =>
s.scopeMetrics.flatMap((sm) =>
sm.metrics.map((m) => m.descriptor.name)
)
);
expect(names).toContain("notifications.created_total");
expect(names).toContain("notifications.read_total");
expect(names).toContain("notifications.deleted_total");
expect(names).toContain("notifications.push_sent_total");
expect(names).toContain("notifications.push_failed_total");
expect(names).toContain("notifications.device_tokens_registered_total");
});
it("resetNotificationsInstruments rebinds against a fresh meter", {
timeout: 5000,
}, () => {
resetNotificationsInstruments();
const a = getNotificationsInstruments();
resetNotificationsInstruments();
const b = getNotificationsInstruments();
expect(a).not.toBe(b);
});
});

View File

@@ -0,0 +1,108 @@
import { type Counter, metrics } from "@opentelemetry/api";
/**
* Bundle of OpenTelemetry instruments emitted by the notifications
* plugin.
*
* - `notificationsCreatedTotal` — incremented on every successful
* `POST /notifications`.
* - `notificationsReadTotal` — incremented on every successful
* `POST /notifications/:id/read` or `markAllAsRead`.
* - `notificationsDeletedTotal` — incremented on every successful
* `DELETE /notifications/:id`.
* - `pushSentTotal{platform}` — incremented per successful APN
* delivery, labelled by platform.
* - `pushFailedTotal{reason}` — incremented per failed APN delivery,
* labelled by reason (e.g. `BadDeviceToken`, `transport_error`).
* - `deviceTokensRegisteredTotal` — incremented every time a fresh
* device-token row is created via `POST /push/tokens`.
*/
export interface NotificationsInstruments {
readonly notificationsCreatedTotal: Counter;
readonly notificationsReadTotal: Counter;
readonly notificationsDeletedTotal: Counter;
readonly pushSentTotal: Counter;
readonly pushFailedTotal: Counter;
readonly deviceTokensRegisteredTotal: Counter;
readonly notificationsDispatchedTotal: Counter;
readonly notificationsDispatchFailedTotal: Counter;
}
let cached: NotificationsInstruments | undefined;
/**
* Lazily build (and cache) the notifications instruments bundle against
* the current global `MeterProvider`. Call
* {@link resetNotificationsInstruments} in test setup to force a
* re-bind against a freshly-installed provider.
*/
export function getNotificationsInstruments(): NotificationsInstruments {
if (cached !== undefined) return cached;
const meter = metrics.getMeter("@suaveplan/api-plugin-notifications");
cached = {
notificationsCreatedTotal: meter.createCounter(
"notifications.created_total",
{
description:
"Total notifications successfully created via POST /notifications",
}
),
notificationsReadTotal: meter.createCounter(
"notifications.read_total",
{
description:
"Total notifications transitioned to read state via mark-read endpoints",
}
),
notificationsDeletedTotal: meter.createCounter(
"notifications.deleted_total",
{
description:
"Total notifications deleted via DELETE /notifications/:id",
}
),
pushSentTotal: meter.createCounter("notifications.push_sent_total", {
description:
"Total push notifications delivered through the APN transport, partitioned by platform",
}),
pushFailedTotal: meter.createCounter(
"notifications.push_failed_total",
{
description:
"Total push notifications that failed to deliver, partitioned by reason",
}
),
deviceTokensRegisteredTotal: meter.createCounter(
"notifications.device_tokens_registered_total",
{
description:
"Total fresh device-token rows created via POST /push/tokens",
}
),
notificationsDispatchedTotal: meter.createCounter(
"notifications.dispatched_total",
{
description:
"Total notifications successfully dispatched through NotificationDispatcher, partitioned by channel",
}
),
notificationsDispatchFailedTotal: meter.createCounter(
"notifications.dispatch_failed_total",
{
description:
"Total notifications that failed dispatch through NotificationDispatcher, partitioned by channel and reason",
}
),
};
return cached;
}
/**
* Drop the cached instrument bundle so the next
* {@link getNotificationsInstruments} call rebinds against the global
* `MeterProvider` in effect at that point. Tests call this after
* installing a fresh `MeterProvider`.
*/
export function resetNotificationsInstruments(): void {
cached = undefined;
}