Initial Commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
getNotificationsInstruments,
|
||||
type NotificationsInstruments,
|
||||
resetNotificationsInstruments,
|
||||
} from "./instruments.js";
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user