@maroonedsoftware/telemetry 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +179 -0
- package/LICENSE +21 -0
- package/README.md +124 -2
- package/dist/chunk-7QVYU63E.js +7 -0
- package/dist/chunk-7QVYU63E.js.map +1 -0
- package/dist/chunk-E7OXUL3D.js +25 -0
- package/dist/chunk-E7OXUL3D.js.map +1 -0
- package/dist/chunk-QS6DX2UZ.js +24 -0
- package/dist/chunk-SHUYVCID.js +6 -0
- package/dist/chunk-Z5AB6WWF.js +24 -0
- package/dist/finished.span.d.ts +44 -0
- package/dist/finished.span.d.ts.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +426 -0
- package/dist/index.js.map +1 -0
- package/dist/kysely.d.ts +2 -0
- package/dist/kysely.d.ts.map +1 -0
- package/dist/kysely.js +229 -0
- package/dist/kysely.js.map +1 -0
- package/dist/metric.aggregate.d.ts +41 -0
- package/dist/metric.aggregate.d.ts.map +1 -0
- package/dist/metric.bucket.d.ts +25 -0
- package/dist/metric.bucket.d.ts.map +1 -0
- package/dist/metric.labels.d.ts +18 -0
- package/dist/metric.labels.d.ts.map +1 -0
- package/dist/metric.resolution.d.ts +19 -0
- package/dist/metric.resolution.d.ts.map +1 -0
- package/dist/metrics.recorder.d.ts +96 -0
- package/dist/metrics.recorder.d.ts.map +1 -0
- package/dist/metrics.store.d.ts +140 -0
- package/dist/metrics.store.d.ts.map +1 -0
- package/dist/otlp.d.ts +4 -0
- package/dist/otlp.d.ts.map +1 -0
- package/dist/otlp.export.d.ts +8 -0
- package/dist/otlp.export.d.ts.map +1 -0
- package/dist/otlp.ids.d.ts +16 -0
- package/dist/otlp.ids.d.ts.map +1 -0
- package/dist/otlp.js +237 -0
- package/dist/otlp.js.map +1 -0
- package/dist/otlp.metrics.bridge.d.ts +64 -0
- package/dist/otlp.metrics.bridge.d.ts.map +1 -0
- package/dist/otlp.metrics.receiver.d.ts +79 -0
- package/dist/otlp.metrics.receiver.d.ts.map +1 -0
- package/dist/otlp.span.bridge.d.ts +44 -0
- package/dist/otlp.span.bridge.d.ts.map +1 -0
- package/package.json +104 -4
package/AGENTS.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# AGENTS.md — @maroonedsoftware/telemetry
|
|
2
|
+
|
|
3
|
+
Machine-oriented guide for AI agents. Human prose and long-form examples live in [README.md](./README.md).
|
|
4
|
+
Repo-wide conventions live in the [root AGENTS.md](../../AGENTS.md).
|
|
5
|
+
|
|
6
|
+
## Purpose
|
|
7
|
+
|
|
8
|
+
Collects application metrics into one-minute buckets in memory (`MetricsRecorder`), so an app can keep
|
|
9
|
+
its own numbers over time and chart them. Not an OpenTelemetry SDK replacement: it holds aggregates, not
|
|
10
|
+
raw observations, and has no percentiles. Reach for the OTel SDK directly when you only need to ship
|
|
11
|
+
metrics to a collector and never read them back.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pnpm add @maroonedsoftware/telemetry injectkit reflect-metadata
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Required peers: none beyond `injectkit`'s own `reflect-metadata`.
|
|
20
|
+
Optional peers: `kysely` — unlocks the `./kysely` subpath (`MetricsStore`). `@opentelemetry/api`, `/core`,
|
|
21
|
+
`/resources`, `/sdk-metrics` and `/sdk-trace` — unlock the `./otlp` subpath. The OTLP exporters themselves
|
|
22
|
+
(`@opentelemetry/exporter-metrics-otlp-http` and the like) are the app's choice and not peers.
|
|
23
|
+
|
|
24
|
+
## Position in the graph
|
|
25
|
+
|
|
26
|
+
- **Depends on:** nothing internal
|
|
27
|
+
- **Depended on by:** nothing internal
|
|
28
|
+
- **Subpath exports:** `./kysely` — `MetricsStore`, the PostgreSQL store; a subpath so the root entry stays free of `kysely`
|
|
29
|
+
- **Subpath exports:** `./otlp` — `OtlpMetricsBridge`, `OtlpSpanBridge` and the id helpers; a subpath so the root entry stays free of the OTel SDK
|
|
30
|
+
|
|
31
|
+
## API surface
|
|
32
|
+
|
|
33
|
+
| Export | Kind | Shape | Notes |
|
|
34
|
+
| ---------------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
|
35
|
+
| `MetricsRecorder` | class | `observe(name, value, labels?, at?)`, `gauge(name, read, options?)`, `sampleGauges(now?)`, `drain()`, `pending`, `onGaugeError?` | `@Injectable()`; register as a singleton |
|
|
36
|
+
| `GaugeReader` | type | `() => number \| undefined \| Promise<number \| undefined>` | `undefined` records nothing |
|
|
37
|
+
| `GaugeOptions` | interface | `{ labels?: MetricLabels; every?: Duration }` | `every` defaults to zero (every pass) |
|
|
38
|
+
| `GaugeErrorHandler` | type | `(name: string, error: unknown) => void` | |
|
|
39
|
+
| `MetricAggregate` | interface | `{ count, sum, min, max, last }` | |
|
|
40
|
+
| `aggregateOf` | function | `(value: number) => MetricAggregate` | |
|
|
41
|
+
| `mergeAggregates` | function | `(previous, next) => MetricAggregate` | associative; `last` is taken from `next` |
|
|
42
|
+
| `MetricBucket` | interface | `{ name, labels, labelsKey, start: DateTime, aggregate }` | `start` is the UTC minute |
|
|
43
|
+
| `minuteOf` | function | `(at: DateTime) => DateTime` | UTC start of minute |
|
|
44
|
+
| `MetricLabels` | type | `Readonly<Record<string, string>>` | string values only |
|
|
45
|
+
| `labelsKey` | function | `(labels: MetricLabels) => string` | sorted JSON; `'{}'` for none |
|
|
46
|
+
| `FinishedSpan` | interface | `{ traceId, spanId, parentSpanId?, name, start, end, ok, error?, attributes?, links? }` | the app's own ids; work already done |
|
|
47
|
+
| `SpanLink` | interface | `{ traceId, spanId }` | |
|
|
48
|
+
| `SpanAttributeValue` | type | `string \| number \| boolean` | |
|
|
49
|
+
| `OtlpMetricsReceiver` | class | `new OtlpMetricsReceiver({ maxSeriesPerName?, maxNames? })`; `receive(body, now?) => OtlpReceiveResult` | OTLP/JSON only; holds cumulative baselines and the caps; one per process |
|
|
50
|
+
| `OtlpMetricsReceiverOptions` | interface | `{ maxSeriesPerName?: number; maxNames?: number }` | defaults 50 and 500 |
|
|
51
|
+
| `OtlpReceiveResult` | interface | `{ buckets: MetricBucket[]; dropped: number; unsupported: number }` | |
|
|
52
|
+
| `OtlpPayloadError` | class | `extends Error` | body is not a metrics export; answer 400 |
|
|
53
|
+
| `MetricResolution` | type | `'minute' \| 'hour'` | |
|
|
54
|
+
| `resolutionFor` | function | `(from, to, limit?) => MetricResolution` | minutes up to `limit` (48h), else hours |
|
|
55
|
+
| `MINUTE_RESOLUTION_LIMIT` | const | `Duration` (48 hours) | |
|
|
56
|
+
|
|
57
|
+
### `./kysely`
|
|
58
|
+
|
|
59
|
+
| Export | Kind | Shape | Notes |
|
|
60
|
+
| --------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
|
61
|
+
| `MetricsStore` | class | `new MetricsStore(db, options)`; `upsertMinutes(buckets, source?)`, `rollupHour(hour)`, `prune(resolution, olderThan, now?)`, `series(query)`, `names()` | runs on `db.withoutPlugins()`; not `@Injectable()`, construct it in a factory |
|
|
62
|
+
| `MetricsStoreOptions` | interface | `{ table: string; scope?: Record<string, string>; batchSize?: number }` | `scope` columns must be in the primary key |
|
|
63
|
+
| `MetricSeriesQuery` | interface | `{ names, from, to, resolution, labels? }` | `to` is exclusive; `labels` matches with `@>` |
|
|
64
|
+
| `MetricSeries` | interface | `{ name, labels, labelsKey, source, points: MetricPoint[] }` | points oldest first |
|
|
65
|
+
| `MetricPoint` | interface | `{ start: DateTime; aggregate: MetricAggregate }` | |
|
|
66
|
+
| `MetricName` | interface | `{ name, source, labelKeys: string[], lastSeen: DateTime }` | |
|
|
67
|
+
|
|
68
|
+
### `./otlp`
|
|
69
|
+
|
|
70
|
+
| Export | Kind | Shape | Notes |
|
|
71
|
+
| -------------------------- | --------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
|
72
|
+
| `OtlpMetricsBridge` | class | `new OtlpMetricsBridge({ exporter, resource?, scope?, kindOf? })`; `export(buckets)`, `shutdown()` | builds `ResourceMetrics` directly, no `MeterProvider`; delta temporality |
|
|
73
|
+
| `OtlpMetricsBridgeOptions` | interface | as above | `kindOf` defaults to `gauge` |
|
|
74
|
+
| `MetricKind` | type | `'gauge' \| 'counter' \| 'histogram'` | gauge → `last`, counter → `sum`, histogram → count/sum/min/max |
|
|
75
|
+
| `OtlpSpanBridge` | class | `new OtlpSpanBridge({ exporter, resource?, scope? })`; `export(spans)`, `shutdown()` | builds finished `ReadableSpan`s with recorded times |
|
|
76
|
+
| `OtlpSpanBridgeOptions` | interface | as above | |
|
|
77
|
+
| `otlpTraceId` | function | `(id: string) => string` | 32 hex; kept if already, else sha256-derived |
|
|
78
|
+
| `otlpSpanId` | function | `(id: string) => string` | 16 hex; same rule |
|
|
79
|
+
|
|
80
|
+
## Canonical usage
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
import { MetricsRecorder } from '@maroonedsoftware/telemetry';
|
|
84
|
+
|
|
85
|
+
registry.register(MetricsRecorder).useClass(MetricsRecorder).asSingleton();
|
|
86
|
+
|
|
87
|
+
const recorder = container.get(MetricsRecorder);
|
|
88
|
+
recorder.observe('work.job.ms', 412, { queue: 'catalog.sync' });
|
|
89
|
+
recorder.gauge('station.listeners', () => audience.count());
|
|
90
|
+
|
|
91
|
+
// your loop, once a minute
|
|
92
|
+
await recorder.sampleGauges();
|
|
93
|
+
await write(recorder.drain());
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
With the store, in the app's own loop and jobs:
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
import { MetricsStore } from '@maroonedsoftware/telemetry/kysely';
|
|
100
|
+
|
|
101
|
+
const store = new MetricsStore(db, { table: 'app.metric_samples' });
|
|
102
|
+
await store.upsertMinutes(recorder.drain()); // every minute
|
|
103
|
+
await store.rollupHour(DateTime.utc().minus({ hours: 1 })); // hourly job
|
|
104
|
+
await store.prune('minute', Duration.fromObject({ days: 7 })); // nightly job
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Exporting over OTLP:
|
|
108
|
+
|
|
109
|
+
```typescript
|
|
110
|
+
import { OtlpMetricsBridge, OtlpSpanBridge } from '@maroonedsoftware/telemetry/otlp';
|
|
111
|
+
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http';
|
|
112
|
+
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
|
|
113
|
+
|
|
114
|
+
const metrics = new OtlpMetricsBridge({ exporter: new OTLPMetricExporter({ url }), kindOf: name => (name.endsWith('.ms') ? 'histogram' : 'gauge') });
|
|
115
|
+
const spans = new OtlpSpanBridge({ exporter: new OTLPTraceExporter({ url }) });
|
|
116
|
+
|
|
117
|
+
const buckets = recorder.drain();
|
|
118
|
+
await store.upsertMinutes(buckets);
|
|
119
|
+
await metrics.export(buckets).catch(log); // a dead collector must not stop the store write
|
|
120
|
+
await spans.export(finishedSinceLastFlush);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Receiving OTLP/JSON from other services, in the app's own route:
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
import { OtlpMetricsReceiver, OtlpPayloadError } from '@maroonedsoftware/telemetry';
|
|
127
|
+
|
|
128
|
+
const receiver = new OtlpMetricsReceiver({ maxSeriesPerName: 50 }); // a singleton
|
|
129
|
+
|
|
130
|
+
const { buckets, dropped } = receiver.receive(ctx.request.body); // throws OtlpPayloadError → 400
|
|
131
|
+
await store.upsertMinutes(buckets, 'otlp');
|
|
132
|
+
ctx.body = dropped > 0 ? { partialSuccess: { rejectedDataPoints: String(dropped) } } : {};
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Rules for generated code
|
|
136
|
+
|
|
137
|
+
- Register `MetricsRecorder` as a singleton. A scoped or transient recorder splits observations across
|
|
138
|
+
instances and the loop drains only one of them.
|
|
139
|
+
- Name metrics in dot notation (`work.job.ms`), with the unit as the last segment when there is one.
|
|
140
|
+
- Keep label values from a small, known set (a queue name, a plugin id). Never put an id, a user, or
|
|
141
|
+
free text in a label: every distinct label set is a row per minute.
|
|
142
|
+
- Drain on shutdown too, after producers have stopped, or the last partial minute is lost.
|
|
143
|
+
- Create the table from the reference DDL in the README, in the app's own migration. Column names are
|
|
144
|
+
fixed; add `scope` columns to the primary key, first.
|
|
145
|
+
- Roll an hour up only once it is complete. Rolling up early is not wrong (the next run replaces the row)
|
|
146
|
+
but the hour row lags until then.
|
|
147
|
+
|
|
148
|
+
- Export to OTLP after the store write, and never let an export failure skip or retry the store write.
|
|
149
|
+
- Give every metric that is a duration or a count a `kindOf` answer; the default sends everything as a gauge.
|
|
150
|
+
|
|
151
|
+
- Keep one `OtlpMetricsReceiver` per process. A new one per request loses every cumulative baseline, so
|
|
152
|
+
cumulative sums and histograms never produce a point.
|
|
153
|
+
- Put received metrics behind authentication and a size limit on the body: the caps bound rows, not bytes.
|
|
154
|
+
|
|
155
|
+
## Gotchas
|
|
156
|
+
|
|
157
|
+
- `drain()` includes the still-open minute. A store must merge (upsert) rather than insert, or a
|
|
158
|
+
minute flushed twice is double-counted or rejected.
|
|
159
|
+
- The OTLP metrics are delta. A collector feeding Prometheus needs the `deltatocumulative` processor.
|
|
160
|
+
- `otlpTraceId` hashes, so a trace id is only stable for the same app id string. Two apps sharing a trace
|
|
161
|
+
must agree on the id string, or pass real 32-hex ids.
|
|
162
|
+
- The receiver reads OTLP/JSON only. A sender on the default `http/protobuf` encoding must be switched to
|
|
163
|
+
`http/json` (`OTEL_EXPORTER_OTLP_METRICS_PROTOCOL=http/json`).
|
|
164
|
+
- A cumulative series' first point, and its first after a sender restart, produce nothing: they set the baseline.
|
|
165
|
+
- The caps last for the receiver's life. A dropped series stays dropped until the process restarts.
|
|
166
|
+
- `observe()` silently drops `NaN` and `Infinity`.
|
|
167
|
+
- `MetricsStore` ignores the app's Kysely plugins. Rows from `series()` are mapped by the store, never
|
|
168
|
+
read raw, so a `CamelCasePlugin` cannot rename its columns underneath it.
|
|
169
|
+
- A gauge whose reader throws is marked sampled anyway, so it is retried at its `every` cadence, not on
|
|
170
|
+
the next pass.
|
|
171
|
+
|
|
172
|
+
## Working inside this package
|
|
173
|
+
|
|
174
|
+
`src/` holds one concern per file (`metric.aggregate.ts`, `metric.labels.ts`, `metric.bucket.ts`,
|
|
175
|
+
`metric.resolution.ts`, `metrics.recorder.ts`) behind the `src/index.ts` barrel, `metrics.store.ts` behind the
|
|
176
|
+
`src/kysely.ts` subpath entry, and the `otlp.*.ts` bridges behind `src/otlp.ts`.
|
|
177
|
+
`otlp.metrics.receiver.ts` is in the root entry on purpose: it parses JSON and needs no OTel SDK; tests mirror them in `tests/`. Keep the root
|
|
178
|
+
entry free of I/O and of external SDKs. A new subpath needs an `exports` entry, a tsup entry in
|
|
179
|
+
`build`, and its SDK as an optional peer. Every user-visible change needs a changeset.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marooned Software
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,125 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @maroonedsoftware/telemetry
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Minute-bucket metrics for ServerKit. Producers observe numbers into an in-process recorder; a loop you own drains it once a minute and writes what it drained wherever you keep metrics.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **One shape for every metric**: each series' minute is five numbers (`count`, `sum`, `min`, `max`, `last`), which read as a gauge, a counter or a duration
|
|
8
|
+
- **Gauge registry**: register a reader once, sample it on every pass or at its own cadence
|
|
9
|
+
- **Merge-friendly**: aggregates are associative, so a later flush of the same minute merges into the row already stored, and minutes fold into hours
|
|
10
|
+
- **No I/O in the recorder**: storage and export are separate, so a dead database or collector never blocks a producer
|
|
11
|
+
- **A PostgreSQL store** (`/kysely`): merging upserts, idempotent hourly rollups, pruning, range reads
|
|
12
|
+
- **OpenTelemetry bridges** (`/otlp`): send the same buckets, and finished spans, to any OTLP exporter
|
|
13
|
+
- **An OTLP/JSON receiver**: read other services' metric exports into the same buckets, with cumulative-to-delta and a cardinality cap
|
|
14
|
+
- **Zero internal dependencies**: [injectkit](https://www.npmjs.com/package/injectkit) and [luxon](https://moment.github.io/luxon/) only
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pnpm add @maroonedsoftware/telemetry injectkit reflect-metadata
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { MetricsRecorder } from '@maroonedsoftware/telemetry';
|
|
26
|
+
import { Duration } from 'luxon';
|
|
27
|
+
|
|
28
|
+
const recorder = new MetricsRecorder(); // register as a singleton in your container
|
|
29
|
+
|
|
30
|
+
// Anywhere work happens:
|
|
31
|
+
recorder.observe('work.job.ms', 412, { queue: 'catalog.sync' });
|
|
32
|
+
|
|
33
|
+
// Once, where a reading lives:
|
|
34
|
+
recorder.gauge('disk.free.bytes', () => disk.free(), { every: Duration.fromObject({ minutes: 15 }) });
|
|
35
|
+
|
|
36
|
+
// In a loop you own, once a minute:
|
|
37
|
+
await recorder.sampleGauges();
|
|
38
|
+
const buckets = recorder.drain();
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Storing metrics in PostgreSQL
|
|
42
|
+
|
|
43
|
+
`@maroonedsoftware/telemetry/kysely` needs `kysely` installed. The table belongs to your app; create it in your own migration:
|
|
44
|
+
|
|
45
|
+
```sql
|
|
46
|
+
create table app.metric_samples (
|
|
47
|
+
resolution text not null check (resolution in ('minute', 'hour')),
|
|
48
|
+
bucket timestamptz not null,
|
|
49
|
+
name text not null,
|
|
50
|
+
labels jsonb not null default '{}',
|
|
51
|
+
labels_key text not null,
|
|
52
|
+
source text not null default 'app',
|
|
53
|
+
count double precision not null,
|
|
54
|
+
sum double precision not null,
|
|
55
|
+
min double precision not null,
|
|
56
|
+
max double precision not null,
|
|
57
|
+
last double precision not null,
|
|
58
|
+
primary key (resolution, name, labels_key, bucket)
|
|
59
|
+
);
|
|
60
|
+
create index metric_samples_bucket on app.metric_samples (resolution, bucket);
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
To keep several tenants in one table, add a column per tenant key to the front of the primary key and the index, and pass it as `scope`.
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
import { MetricsStore } from '@maroonedsoftware/telemetry/kysely';
|
|
67
|
+
import { resolutionFor } from '@maroonedsoftware/telemetry';
|
|
68
|
+
|
|
69
|
+
const store = new MetricsStore(db, { table: 'app.metric_samples' });
|
|
70
|
+
|
|
71
|
+
await store.upsertMinutes(recorder.drain()); // a later flush of the same minute merges in
|
|
72
|
+
await store.rollupHour(DateTime.utc().minus({ hours: 1 })); // idempotent: replaces the hour row
|
|
73
|
+
await store.prune('minute', Duration.fromObject({ days: 7 }));
|
|
74
|
+
await store.prune('hour', Duration.fromObject({ days: 400 }));
|
|
75
|
+
|
|
76
|
+
const series = await store.series({ names: ['work.job.ms'], from, to, resolution: resolutionFor(from, to) });
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Exporting over OTLP
|
|
80
|
+
|
|
81
|
+
`@maroonedsoftware/telemetry/otlp` needs `@opentelemetry/api`, `/core`, `/resources`, `/sdk-metrics` and `/sdk-trace`, plus whichever exporter you send with.
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
import { OtlpMetricsBridge, OtlpSpanBridge } from '@maroonedsoftware/telemetry/otlp';
|
|
85
|
+
|
|
86
|
+
const metrics = new OtlpMetricsBridge({
|
|
87
|
+
exporter: new OTLPMetricExporter({ url: 'http://collector:4318/v1/metrics' }),
|
|
88
|
+
kindOf: name => (name.endsWith('.ms') ? 'histogram' : name.endsWith('.count') ? 'counter' : 'gauge'),
|
|
89
|
+
});
|
|
90
|
+
await metrics.export(buckets);
|
|
91
|
+
|
|
92
|
+
const spans = new OtlpSpanBridge({ exporter: new OTLPTraceExporter({ url: 'http://collector:4318/v1/traces' }) });
|
|
93
|
+
await spans.export([{ traceId: 'job-42', spanId: 'job-42/run', name: 'job.run', start, end, ok: true }]);
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Metrics go out with delta temporality: each bucket is one point spanning its minute. Spans keep the times you recorded, and your own ids are hashed into OTLP form so the same id always lands in the same trace.
|
|
97
|
+
|
|
98
|
+
## Receiving OTLP/JSON
|
|
99
|
+
|
|
100
|
+
`OtlpMetricsReceiver` turns the body of an OTLP/HTTP `POST /v1/metrics` sent as JSON into minute buckets, so other services' metrics land in the same store. It needs no OTel packages.
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
import { OtlpMetricsReceiver } from '@maroonedsoftware/telemetry';
|
|
104
|
+
|
|
105
|
+
const receiver = new OtlpMetricsReceiver({ maxSeriesPerName: 50, maxNames: 500 }); // keep one per process
|
|
106
|
+
|
|
107
|
+
const { buckets, dropped, unsupported } = receiver.receive(body);
|
|
108
|
+
await store.upsertMinutes(buckets, 'otlp');
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Gauges and up-down sums are read as values; monotonic sums and histograms contribute their increase, with cumulative series differenced against their previous point. Exponential histograms and summaries are counted as unsupported. Senders must use `http/json`, not protobuf.
|
|
112
|
+
|
|
113
|
+
## Reading an aggregate
|
|
114
|
+
|
|
115
|
+
| Kind | Read |
|
|
116
|
+
| -------- | ------------------------------------------- |
|
|
117
|
+
| Gauge | `last`, or `sum / count` for the mean |
|
|
118
|
+
| Counter | `sum` |
|
|
119
|
+
| Duration | `sum / count` for the mean, `max` for worst |
|
|
120
|
+
|
|
121
|
+
There are no percentiles: those need a histogram or sketch per bucket.
|
|
122
|
+
|
|
123
|
+
## License
|
|
124
|
+
|
|
125
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__name
|
|
3
|
+
} from "./chunk-7QVYU63E.js";
|
|
4
|
+
|
|
5
|
+
// src/metric.aggregate.ts
|
|
6
|
+
var aggregateOf = /* @__PURE__ */ __name((value) => ({
|
|
7
|
+
count: 1,
|
|
8
|
+
sum: value,
|
|
9
|
+
min: value,
|
|
10
|
+
max: value,
|
|
11
|
+
last: value
|
|
12
|
+
}), "aggregateOf");
|
|
13
|
+
var mergeAggregates = /* @__PURE__ */ __name((previous, next) => ({
|
|
14
|
+
count: previous.count + next.count,
|
|
15
|
+
sum: previous.sum + next.sum,
|
|
16
|
+
min: Math.min(previous.min, next.min),
|
|
17
|
+
max: Math.max(previous.max, next.max),
|
|
18
|
+
last: next.last
|
|
19
|
+
}), "mergeAggregates");
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
aggregateOf,
|
|
23
|
+
mergeAggregates
|
|
24
|
+
};
|
|
25
|
+
//# sourceMappingURL=chunk-E7OXUL3D.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/metric.aggregate.ts"],"sourcesContent":["/**\n * Five numbers that summarise every observation of one series inside one bucket.\n *\n * One shape covers all three kinds of metric, so a store needs a single row type:\n * - a **gauge** reads `last` (or `sum / count` for the bucket's mean),\n * - a **counter** reads `sum`,\n * - a **duration** reads `sum / count` for the mean and `max` for the worst.\n *\n * There are no percentiles. They need a histogram or a sketch per bucket, and the\n * mean beside the max is enough to see that something got slow.\n */\nexport interface MetricAggregate {\n /** How many observations were folded in. */\n count: number;\n /** Their total. */\n sum: number;\n /** The smallest observation. */\n min: number;\n /** The largest observation. */\n max: number;\n /** The most recent observation. */\n last: number;\n}\n\n/**\n * The aggregate of a single observation.\n *\n * @param value - The observed value. The caller is expected to have rejected non-finite values.\n */\nexport const aggregateOf = (value: number): MetricAggregate => ({ count: 1, sum: value, min: value, max: value, last: value });\n\n/**\n * Folds `next` into `previous`.\n *\n * Associative, so buckets can be merged in any grouping (a flush into a stored row, minutes into an\n * hour). Not commutative in `last`: `next` is taken to be the later of the two.\n *\n * @param previous - The earlier aggregate.\n * @param next - The later aggregate.\n * @returns A new aggregate; neither input is modified.\n */\nexport const mergeAggregates = (previous: MetricAggregate, next: MetricAggregate): MetricAggregate => ({\n count: previous.count + next.count,\n sum: previous.sum + next.sum,\n min: Math.min(previous.min, next.min),\n max: Math.max(previous.max, next.max),\n last: next.last,\n});\n"],"mappings":";;;;;AA6BO,IAAMA,cAAc,wBAACC,WAAoC;EAAEC,OAAO;EAAGC,KAAKF;EAAOG,KAAKH;EAAOI,KAAKJ;EAAOK,MAAML;AAAM,IAAjG;AAYpB,IAAMM,kBAAkB,wBAACC,UAA2BC,UAA4C;EACrGP,OAAOM,SAASN,QAAQO,KAAKP;EAC7BC,KAAKK,SAASL,MAAMM,KAAKN;EACzBC,KAAKM,KAAKN,IAAII,SAASJ,KAAKK,KAAKL,GAAG;EACpCC,KAAKK,KAAKL,IAAIG,SAASH,KAAKI,KAAKJ,GAAG;EACpCC,MAAMG,KAAKH;AACb,IAN+B;","names":["aggregateOf","value","count","sum","min","max","last","mergeAggregates","previous","next","Math"]}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
3
|
+
|
|
4
|
+
// src/metric.aggregate.ts
|
|
5
|
+
var aggregateOf = /* @__PURE__ */ __name((value) => ({
|
|
6
|
+
count: 1,
|
|
7
|
+
sum: value,
|
|
8
|
+
min: value,
|
|
9
|
+
max: value,
|
|
10
|
+
last: value
|
|
11
|
+
}), "aggregateOf");
|
|
12
|
+
var mergeAggregates = /* @__PURE__ */ __name((previous, next) => ({
|
|
13
|
+
count: previous.count + next.count,
|
|
14
|
+
sum: previous.sum + next.sum,
|
|
15
|
+
min: Math.min(previous.min, next.min),
|
|
16
|
+
max: Math.max(previous.max, next.max),
|
|
17
|
+
last: next.last
|
|
18
|
+
}), "mergeAggregates");
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
__name,
|
|
22
|
+
aggregateOf,
|
|
23
|
+
mergeAggregates
|
|
24
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__name
|
|
3
|
+
} from "./chunk-SHUYVCID.js";
|
|
4
|
+
|
|
5
|
+
// src/metric.aggregate.ts
|
|
6
|
+
var aggregateOf = /* @__PURE__ */ __name((value) => ({
|
|
7
|
+
count: 1,
|
|
8
|
+
sum: value,
|
|
9
|
+
min: value,
|
|
10
|
+
max: value,
|
|
11
|
+
last: value
|
|
12
|
+
}), "aggregateOf");
|
|
13
|
+
var mergeAggregates = /* @__PURE__ */ __name((previous, next) => ({
|
|
14
|
+
count: previous.count + next.count,
|
|
15
|
+
sum: previous.sum + next.sum,
|
|
16
|
+
min: Math.min(previous.min, next.min),
|
|
17
|
+
max: Math.max(previous.max, next.max),
|
|
18
|
+
last: next.last
|
|
19
|
+
}), "mergeAggregates");
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
aggregateOf,
|
|
23
|
+
mergeAggregates
|
|
24
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { DateTime } from 'luxon';
|
|
2
|
+
/**
|
|
3
|
+
* An attribute value on a {@link FinishedSpan}.
|
|
4
|
+
*/
|
|
5
|
+
export type SpanAttributeValue = string | number | boolean;
|
|
6
|
+
/**
|
|
7
|
+
* A reference from one span to another that is not its parent: work one decision caused in another
|
|
8
|
+
* trace, say.
|
|
9
|
+
*/
|
|
10
|
+
export interface SpanLink {
|
|
11
|
+
traceId: string;
|
|
12
|
+
spanId: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A unit of work that has already happened, described after the fact.
|
|
16
|
+
*
|
|
17
|
+
* Apps that time their own work (a job runner wrapping each run, a tool loop timing each call) can
|
|
18
|
+
* describe it in this plain shape and hand it to an exporter, rather than threading a live tracer
|
|
19
|
+
* through the work. The ids are the app's own strings: an exporter that needs a particular format
|
|
20
|
+
* (OTLP's 16- and 8-byte hex) derives one from them.
|
|
21
|
+
*/
|
|
22
|
+
export interface FinishedSpan {
|
|
23
|
+
/** Groups the spans of one piece of work. */
|
|
24
|
+
traceId: string;
|
|
25
|
+
/** Identifies this span within its trace. */
|
|
26
|
+
spanId: string;
|
|
27
|
+
/** The span this one ran inside, in the same trace. */
|
|
28
|
+
parentSpanId?: string;
|
|
29
|
+
/** What the work was: `job.run`, `plugin.invoke`. */
|
|
30
|
+
name: string;
|
|
31
|
+
/** When it began. */
|
|
32
|
+
start: DateTime;
|
|
33
|
+
/** When it ended. */
|
|
34
|
+
end: DateTime;
|
|
35
|
+
/** Whether it succeeded. */
|
|
36
|
+
ok: boolean;
|
|
37
|
+
/** Why it failed, when it did. Never sent as anything but a status message. */
|
|
38
|
+
error?: string;
|
|
39
|
+
/** Anything else worth keeping about it. */
|
|
40
|
+
attributes?: Readonly<Record<string, SpanAttributeValue>>;
|
|
41
|
+
/** Spans in other traces this one relates to. */
|
|
42
|
+
links?: readonly SpanLink[];
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=finished.span.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"finished.span.d.ts","sourceRoot":"","sources":["../src/finished.span.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAEjC;;GAEG;AACH,MAAM,MAAM,kBAAkB,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAE3D;;;GAGG;AACH,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;;GAOG;AACH,MAAM,WAAW,YAAY;IAC3B,6CAA6C;IAC7C,OAAO,EAAE,MAAM,CAAC;IAChB,6CAA6C;IAC7C,MAAM,EAAE,MAAM,CAAC;IACf,uDAAuD;IACvD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,qDAAqD;IACrD,IAAI,EAAE,MAAM,CAAC;IACb,qBAAqB;IACrB,KAAK,EAAE,QAAQ,CAAC;IAChB,qBAAqB;IACrB,GAAG,EAAE,QAAQ,CAAC;IACd,4BAA4B;IAC5B,EAAE,EAAE,OAAO,CAAC;IACZ,+EAA+E;IAC/E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,4CAA4C;IAC5C,UAAU,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAC,CAAC;IAC1D,iDAAiD;IACjD,KAAK,CAAC,EAAE,SAAS,QAAQ,EAAE,CAAC;CAC7B"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './finished.span.js';
|
|
2
|
+
export * from './metric.aggregate.js';
|
|
3
|
+
export * from './metric.bucket.js';
|
|
4
|
+
export * from './metric.labels.js';
|
|
5
|
+
export * from './metric.resolution.js';
|
|
6
|
+
export * from './metrics.recorder.js';
|
|
7
|
+
export * from './otlp.metrics.receiver.js';
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,wBAAwB,CAAC;AACvC,cAAc,uBAAuB,CAAC;AACtC,cAAc,4BAA4B,CAAC"}
|