@maroonedsoftware/telemetry 0.0.0-stage → 0.1.1
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/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 +237 -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 +143 -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 +103 -3
package/dist/kysely.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mergeAggregates
|
|
3
|
+
} from "./chunk-E7OXUL3D.js";
|
|
4
|
+
import {
|
|
5
|
+
__name
|
|
6
|
+
} from "./chunk-7QVYU63E.js";
|
|
7
|
+
|
|
8
|
+
// src/metrics.store.ts
|
|
9
|
+
import { sql } from "kysely";
|
|
10
|
+
import { DateTime } from "luxon";
|
|
11
|
+
var PRIMARY_KEY = [
|
|
12
|
+
"resolution",
|
|
13
|
+
"name",
|
|
14
|
+
"labels_key",
|
|
15
|
+
"bucket"
|
|
16
|
+
];
|
|
17
|
+
var instantOf = /* @__PURE__ */ __name((value) => {
|
|
18
|
+
if (DateTime.isDateTime(value)) {
|
|
19
|
+
return value.toUTC();
|
|
20
|
+
}
|
|
21
|
+
if (value instanceof Date) {
|
|
22
|
+
return DateTime.fromJSDate(value, {
|
|
23
|
+
zone: "utc"
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
return DateTime.fromISO(value.replace(" ", "T"), {
|
|
27
|
+
zone: "utc"
|
|
28
|
+
});
|
|
29
|
+
}, "instantOf");
|
|
30
|
+
var MetricsStore = class {
|
|
31
|
+
static {
|
|
32
|
+
__name(this, "MetricsStore");
|
|
33
|
+
}
|
|
34
|
+
db;
|
|
35
|
+
table;
|
|
36
|
+
scope;
|
|
37
|
+
batchSize;
|
|
38
|
+
/**
|
|
39
|
+
* @param db - Any Kysely instance on the database holding the table.
|
|
40
|
+
* @param options - The table and scope.
|
|
41
|
+
*/
|
|
42
|
+
constructor(db, options) {
|
|
43
|
+
this.db = db.withoutPlugins();
|
|
44
|
+
this.table = options.table;
|
|
45
|
+
this.scope = Object.entries(options.scope ?? {});
|
|
46
|
+
this.batchSize = options.batchSize ?? 500;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Writes minute buckets, merging each into the row already stored for its series and minute.
|
|
50
|
+
*
|
|
51
|
+
* A minute flushed twice therefore adds up rather than failing or being overwritten, which is what
|
|
52
|
+
* lets a recorder drain its still-open minute. Buckets for the same series and minute in one call
|
|
53
|
+
* are merged first, since PostgreSQL refuses to update one row twice in a statement.
|
|
54
|
+
*
|
|
55
|
+
* @param buckets - What a recorder drained.
|
|
56
|
+
* @param source - Recorded on each row; tells an app's own metrics from received ones. Defaults to `'app'`.
|
|
57
|
+
* @returns How many rows were written.
|
|
58
|
+
*/
|
|
59
|
+
async upsertMinutes(buckets, source = "app") {
|
|
60
|
+
const merged = /* @__PURE__ */ new Map();
|
|
61
|
+
for (const bucket of buckets) {
|
|
62
|
+
const id = `${bucket.name}\0${bucket.labelsKey}\0${bucket.start.toMillis()}`;
|
|
63
|
+
const existing = merged.get(id);
|
|
64
|
+
merged.set(id, existing === void 0 ? bucket : {
|
|
65
|
+
...existing,
|
|
66
|
+
aggregate: mergeAggregates(existing.aggregate, bucket.aggregate)
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const rows = [
|
|
70
|
+
...merged.values()
|
|
71
|
+
].map((bucket) => ({
|
|
72
|
+
...Object.fromEntries(this.scope),
|
|
73
|
+
resolution: "minute",
|
|
74
|
+
bucket: bucket.start.toUTC().toJSDate(),
|
|
75
|
+
name: bucket.name,
|
|
76
|
+
labels: JSON.stringify(bucket.labels),
|
|
77
|
+
labels_key: bucket.labelsKey,
|
|
78
|
+
source,
|
|
79
|
+
...bucket.aggregate
|
|
80
|
+
}));
|
|
81
|
+
let written = 0;
|
|
82
|
+
for (let at = 0; at < rows.length; at += this.batchSize) {
|
|
83
|
+
const batch = rows.slice(at, at + this.batchSize);
|
|
84
|
+
const result = await sql`
|
|
85
|
+
insert into ${sql.table(this.table)} as stored (${sql.join(Object.keys(batch[0] ?? {}).map((column) => sql.ref(column)))})
|
|
86
|
+
values ${sql.join(batch.map((row) => sql`(${sql.join(Object.values(row))})`))}
|
|
87
|
+
on conflict (${this.conflictTarget()}) do update set
|
|
88
|
+
count = ${this.column("count")} + excluded.count,
|
|
89
|
+
sum = ${this.column("sum")} + excluded.sum,
|
|
90
|
+
min = least(${this.column("min")}, excluded.min),
|
|
91
|
+
max = greatest(${this.column("max")}, excluded.max),
|
|
92
|
+
last = excluded.last
|
|
93
|
+
`.execute(this.db);
|
|
94
|
+
written += Number(result.numAffectedRows ?? 0n);
|
|
95
|
+
}
|
|
96
|
+
return written;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Folds one hour of minute rows into hour rows.
|
|
100
|
+
*
|
|
101
|
+
* Idempotent: the hour row is replaced, not added to, so running it twice (a retried job, an
|
|
102
|
+
* overlapping schedule) leaves the same answer. Run it once the hour is complete; run before then
|
|
103
|
+
* and a later run corrects it.
|
|
104
|
+
*
|
|
105
|
+
* @param hourStart - Any instant inside the hour to roll up; it is truncated to the UTC hour.
|
|
106
|
+
* @returns How many hour rows were written.
|
|
107
|
+
*/
|
|
108
|
+
async rollupHour(hourStart) {
|
|
109
|
+
const from = hourStart.toUTC().startOf("hour");
|
|
110
|
+
const to = from.plus({
|
|
111
|
+
hours: 1
|
|
112
|
+
});
|
|
113
|
+
const scopeColumns = this.scope.map(([column]) => sql`${sql.ref(column)}, `);
|
|
114
|
+
const scopeValues = this.scope.map(([, value]) => sql`${value}, `);
|
|
115
|
+
const result = await sql`
|
|
116
|
+
insert into ${sql.table(this.table)} (${sql.join(scopeColumns, sql``)}resolution, bucket, name, labels, labels_key, source, count, sum, min, max, last)
|
|
117
|
+
select ${sql.join(scopeValues, sql``)}'hour', ${from.toJSDate()}, name, (array_agg(labels))[1], labels_key, (array_agg(source))[1],
|
|
118
|
+
sum(count), sum(sum), min(min), max(max), (array_agg(last order by bucket desc))[1]
|
|
119
|
+
from ${sql.table(this.table)}
|
|
120
|
+
where ${this.where(sql`resolution = 'minute'`, sql`bucket >= ${from.toJSDate()}`, sql`bucket < ${to.toJSDate()}`)}
|
|
121
|
+
group by name, labels_key
|
|
122
|
+
on conflict (${this.conflictTarget()}) do update set
|
|
123
|
+
labels = excluded.labels, source = excluded.source,
|
|
124
|
+
count = excluded.count, sum = excluded.sum, min = excluded.min, max = excluded.max, last = excluded.last
|
|
125
|
+
`.execute(this.db);
|
|
126
|
+
return Number(result.numAffectedRows ?? 0n);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Deletes rows of one resolution older than `olderThan`.
|
|
130
|
+
*
|
|
131
|
+
* @param resolution - Which rows.
|
|
132
|
+
* @param olderThan - How old a row's bucket must be to go.
|
|
133
|
+
* @param now - The instant ages are measured from. Defaults to now.
|
|
134
|
+
* @returns How many rows were deleted.
|
|
135
|
+
*/
|
|
136
|
+
async prune(resolution, olderThan, now = DateTime.now()) {
|
|
137
|
+
const cutoff = now.minus(olderThan).toUTC().toJSDate();
|
|
138
|
+
const result = await sql`
|
|
139
|
+
delete from ${sql.table(this.table)}
|
|
140
|
+
where ${this.where(sql`resolution = ${resolution}`, sql`bucket < ${cutoff}`)}
|
|
141
|
+
`.execute(this.db);
|
|
142
|
+
return Number(result.numAffectedRows ?? 0n);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Reads series over a range, one entry per name and label set, points oldest first.
|
|
146
|
+
*
|
|
147
|
+
* @param query - The names, range, resolution and label filter.
|
|
148
|
+
*/
|
|
149
|
+
async series(query) {
|
|
150
|
+
if (query.names.length === 0) {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
const conditions = [
|
|
154
|
+
sql`resolution = ${query.resolution}`,
|
|
155
|
+
sql`name in (${sql.join([
|
|
156
|
+
...query.names
|
|
157
|
+
])})`,
|
|
158
|
+
sql`bucket >= ${query.from.toUTC().toJSDate()}`,
|
|
159
|
+
sql`bucket < ${query.to.toUTC().toJSDate()}`
|
|
160
|
+
];
|
|
161
|
+
if (query.labels !== void 0 && Object.keys(query.labels).length > 0) {
|
|
162
|
+
conditions.push(sql`labels @> ${JSON.stringify(query.labels)}::jsonb`);
|
|
163
|
+
}
|
|
164
|
+
const { rows } = await sql`
|
|
165
|
+
select name, labels, labels_key, source, bucket, count, sum, min, max, last
|
|
166
|
+
from ${sql.table(this.table)}
|
|
167
|
+
where ${this.where(...conditions)}
|
|
168
|
+
order by name, labels_key, bucket
|
|
169
|
+
`.execute(this.db);
|
|
170
|
+
const series = [];
|
|
171
|
+
let current;
|
|
172
|
+
for (const row of rows) {
|
|
173
|
+
if (current === void 0 || current.name !== row.name || current.labelsKey !== row.labels_key) {
|
|
174
|
+
const labels = typeof row.labels === "string" ? JSON.parse(row.labels) : row.labels;
|
|
175
|
+
current = {
|
|
176
|
+
name: row.name,
|
|
177
|
+
labels,
|
|
178
|
+
labelsKey: row.labels_key,
|
|
179
|
+
source: row.source,
|
|
180
|
+
points: []
|
|
181
|
+
};
|
|
182
|
+
series.push(current);
|
|
183
|
+
}
|
|
184
|
+
current.points.push({
|
|
185
|
+
start: instantOf(row.bucket),
|
|
186
|
+
aggregate: {
|
|
187
|
+
count: row.count,
|
|
188
|
+
sum: row.sum,
|
|
189
|
+
min: row.min,
|
|
190
|
+
max: row.max,
|
|
191
|
+
last: row.last
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return series;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Lists every metric name the store holds, with its source, label keys and newest bucket.
|
|
199
|
+
*/
|
|
200
|
+
async names() {
|
|
201
|
+
const { rows } = await sql`
|
|
202
|
+
select name, source, max(bucket) as last_seen, array_agg(distinct key) filter (where key is not null) as label_keys
|
|
203
|
+
from ${sql.table(this.table)} left join lateral jsonb_object_keys(labels) as key on true
|
|
204
|
+
where ${this.where(sql`true`)}
|
|
205
|
+
group by name, source
|
|
206
|
+
order by name, source
|
|
207
|
+
`.execute(this.db);
|
|
208
|
+
return rows.map((row) => ({
|
|
209
|
+
name: row.name,
|
|
210
|
+
source: row.source,
|
|
211
|
+
labelKeys: [
|
|
212
|
+
...row.label_keys ?? []
|
|
213
|
+
].sort(),
|
|
214
|
+
lastSeen: instantOf(row.last_seen)
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
/** A column of the row already stored, inside an upsert's `do update`; the insert aliases the table `stored`. */
|
|
218
|
+
column(name) {
|
|
219
|
+
return sql.ref(`stored.${name}`);
|
|
220
|
+
}
|
|
221
|
+
conflictTarget() {
|
|
222
|
+
return sql.join([
|
|
223
|
+
...this.scope.map(([column]) => column),
|
|
224
|
+
...PRIMARY_KEY
|
|
225
|
+
].map((column) => sql.ref(column)));
|
|
226
|
+
}
|
|
227
|
+
where(...conditions) {
|
|
228
|
+
return sql.join([
|
|
229
|
+
...this.scope.map(([column, value]) => sql`${sql.ref(column)} = ${value}`),
|
|
230
|
+
...conditions
|
|
231
|
+
], sql` and `);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
export {
|
|
235
|
+
MetricsStore
|
|
236
|
+
};
|
|
237
|
+
//# sourceMappingURL=kysely.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/metrics.store.ts"],"sourcesContent":["import { Kysely, RawBuilder, sql } from 'kysely';\nimport { DateTime, Duration } from 'luxon';\nimport { mergeAggregates, MetricAggregate } from './metric.aggregate.js';\nimport { MetricBucket } from './metric.bucket.js';\nimport { MetricLabels } from './metric.labels.js';\nimport { MetricResolution } from './metric.resolution.js';\n\n/**\n * Where a {@link MetricsStore} keeps its rows.\n */\nexport interface MetricsStoreOptions {\n /** The table, schema-qualified if needed: `'metrics.metric_samples'`. */\n table: string;\n /**\n * Fixed column values every row carries and every read filters on, e.g. `{ tenant_key: 'main' }`\n * for an app that keeps several tenants in one table. Each column must be part of the table's\n * primary key. Column names are written as they are in the database.\n */\n scope?: Readonly<Record<string, string>>;\n /** Rows per `insert` statement when upserting. Defaults to 500. */\n batchSize?: number;\n}\n\n/**\n * One stored point of a series.\n */\nexport interface MetricPoint {\n /** The start of the point's minute or hour, in UTC. */\n start: DateTime;\n /** The point's aggregate. */\n aggregate: MetricAggregate;\n}\n\n/**\n * One series read back from the store, its points oldest first.\n */\nexport interface MetricSeries {\n name: string;\n labels: MetricLabels;\n labelsKey: string;\n source: string;\n points: MetricPoint[];\n}\n\n/**\n * What to read with {@link MetricsStore.series}.\n */\nexport interface MetricSeriesQuery {\n /** The metric names to read. An empty list reads nothing. */\n names: readonly string[];\n /** Inclusive start. */\n from: DateTime;\n /** Exclusive end. */\n to: DateTime;\n /** Which rows to read. See `resolutionFor`. */\n resolution: MetricResolution;\n /** Only series whose labels contain all of these pairs. */\n labels?: MetricLabels;\n}\n\n/**\n * A metric name the store holds, for building a picker.\n */\nexport interface MetricName {\n name: string;\n source: string;\n /** Every label key any of its series carries, sorted. */\n labelKeys: string[];\n /** The newest bucket stored for it, at any resolution. */\n lastSeen: DateTime;\n}\n\ninterface SeriesRow {\n name: string;\n labels: MetricLabels | string;\n labels_key: string;\n source: string;\n bucket: DateTime | Date | string;\n count: number;\n sum: number;\n min: number;\n max: number;\n last: number;\n}\n\ninterface NameRow {\n name: string;\n source: string;\n last_seen: DateTime | Date | string;\n label_keys: string[] | null;\n}\n\nconst PRIMARY_KEY = ['resolution', 'name', 'labels_key', 'bucket'] as const;\n\n/**\n * Reads a `timestamptz` however the pool hands it back: a `DateTime` under\n * `@maroonedsoftware/kysely`'s `KyselyPgTypeOverrides`, a `Date` under `pg`'s defaults, or text under\n * a parser that returns the raw value.\n */\nconst instantOf = (value: DateTime | Date | string): DateTime => {\n if (DateTime.isDateTime(value)) {\n return value.toUTC();\n }\n if (value instanceof Date) {\n return DateTime.fromJSDate(value, { zone: 'utc' });\n }\n return DateTime.fromISO(value.replace(' ', 'T'), { zone: 'utc' });\n};\n\n/**\n * Keeps metric buckets in one PostgreSQL table at two resolutions, minutes and hours.\n *\n * The table is the app's own, created by its own migration from the reference DDL in this package's\n * README. The store only reads and writes it.\n *\n * It runs on `db.withoutPlugins()`, so the app's `CamelCasePlugin` (or any other) never rewrites the\n * snake_case columns this class names, and rows come back exactly as PostgreSQL sends them.\n *\n * @example\n * ```typescript\n * const store = new MetricsStore(db, { table: 'app.metric_samples' });\n * await store.upsertMinutes(recorder.drain());\n * await store.rollupHour(DateTime.utc().startOf('hour').minus({ hours: 1 }));\n * ```\n *\n * @typeParam DB - The app's database type. The store never uses it (every statement is raw SQL on the\n * table it was given), so any `Kysely<DB>` is accepted as it is, with no cast at the call site.\n */\nexport class MetricsStore<DB = unknown> {\n private readonly db: Kysely<DB>;\n private readonly table: string;\n private readonly scope: [string, string][];\n private readonly batchSize: number;\n\n /**\n * @param db - Any Kysely instance on the database holding the table.\n * @param options - The table and scope.\n */\n constructor(db: Kysely<DB>, options: MetricsStoreOptions) {\n this.db = db.withoutPlugins();\n this.table = options.table;\n this.scope = Object.entries(options.scope ?? {});\n this.batchSize = options.batchSize ?? 500;\n }\n\n /**\n * Writes minute buckets, merging each into the row already stored for its series and minute.\n *\n * A minute flushed twice therefore adds up rather than failing or being overwritten, which is what\n * lets a recorder drain its still-open minute. Buckets for the same series and minute in one call\n * are merged first, since PostgreSQL refuses to update one row twice in a statement.\n *\n * @param buckets - What a recorder drained.\n * @param source - Recorded on each row; tells an app's own metrics from received ones. Defaults to `'app'`.\n * @returns How many rows were written.\n */\n async upsertMinutes(buckets: readonly MetricBucket[], source = 'app'): Promise<number> {\n const merged = new Map<string, MetricBucket>();\n for (const bucket of buckets) {\n const id = `${bucket.name}\\u0000${bucket.labelsKey}\\u0000${bucket.start.toMillis()}`;\n const existing = merged.get(id);\n merged.set(id, existing === undefined ? bucket : { ...existing, aggregate: mergeAggregates(existing.aggregate, bucket.aggregate) });\n }\n const rows = [...merged.values()].map(bucket => ({\n ...Object.fromEntries(this.scope),\n resolution: 'minute',\n bucket: bucket.start.toUTC().toJSDate(),\n name: bucket.name,\n labels: JSON.stringify(bucket.labels),\n labels_key: bucket.labelsKey,\n source,\n ...bucket.aggregate,\n }));\n\n let written = 0;\n for (let at = 0; at < rows.length; at += this.batchSize) {\n const batch = rows.slice(at, at + this.batchSize);\n const result = await sql`\n insert into ${sql.table(this.table)} as stored (${sql.join(Object.keys(batch[0] ?? {}).map(column => sql.ref(column)))})\n values ${sql.join(batch.map(row => sql`(${sql.join(Object.values(row))})`))}\n on conflict (${this.conflictTarget()}) do update set\n count = ${this.column('count')} + excluded.count,\n sum = ${this.column('sum')} + excluded.sum,\n min = least(${this.column('min')}, excluded.min),\n max = greatest(${this.column('max')}, excluded.max),\n last = excluded.last\n `.execute(this.db);\n written += Number(result.numAffectedRows ?? 0n);\n }\n return written;\n }\n\n /**\n * Folds one hour of minute rows into hour rows.\n *\n * Idempotent: the hour row is replaced, not added to, so running it twice (a retried job, an\n * overlapping schedule) leaves the same answer. Run it once the hour is complete; run before then\n * and a later run corrects it.\n *\n * @param hourStart - Any instant inside the hour to roll up; it is truncated to the UTC hour.\n * @returns How many hour rows were written.\n */\n async rollupHour(hourStart: DateTime): Promise<number> {\n const from = hourStart.toUTC().startOf('hour');\n const to = from.plus({ hours: 1 });\n const scopeColumns = this.scope.map(([column]) => sql`${sql.ref(column)}, `);\n const scopeValues = this.scope.map(([, value]) => sql`${value}, `);\n\n const result = await sql`\n insert into ${sql.table(this.table)} (${sql.join(scopeColumns, sql``)}resolution, bucket, name, labels, labels_key, source, count, sum, min, max, last)\n select ${sql.join(scopeValues, sql``)}'hour', ${from.toJSDate()}, name, (array_agg(labels))[1], labels_key, (array_agg(source))[1],\n sum(count), sum(sum), min(min), max(max), (array_agg(last order by bucket desc))[1]\n from ${sql.table(this.table)}\n where ${this.where(sql`resolution = 'minute'`, sql`bucket >= ${from.toJSDate()}`, sql`bucket < ${to.toJSDate()}`)}\n group by name, labels_key\n on conflict (${this.conflictTarget()}) do update set\n labels = excluded.labels, source = excluded.source,\n count = excluded.count, sum = excluded.sum, min = excluded.min, max = excluded.max, last = excluded.last\n `.execute(this.db);\n return Number(result.numAffectedRows ?? 0n);\n }\n\n /**\n * Deletes rows of one resolution older than `olderThan`.\n *\n * @param resolution - Which rows.\n * @param olderThan - How old a row's bucket must be to go.\n * @param now - The instant ages are measured from. Defaults to now.\n * @returns How many rows were deleted.\n */\n async prune(resolution: MetricResolution, olderThan: Duration, now: DateTime = DateTime.now()): Promise<number> {\n const cutoff = now.minus(olderThan).toUTC().toJSDate();\n const result = await sql`\n delete from ${sql.table(this.table)}\n where ${this.where(sql`resolution = ${resolution}`, sql`bucket < ${cutoff}`)}\n `.execute(this.db);\n return Number(result.numAffectedRows ?? 0n);\n }\n\n /**\n * Reads series over a range, one entry per name and label set, points oldest first.\n *\n * @param query - The names, range, resolution and label filter.\n */\n async series(query: MetricSeriesQuery): Promise<MetricSeries[]> {\n if (query.names.length === 0) {\n return [];\n }\n const conditions: RawBuilder<unknown>[] = [\n sql`resolution = ${query.resolution}`,\n sql`name in (${sql.join([...query.names])})`,\n sql`bucket >= ${query.from.toUTC().toJSDate()}`,\n sql`bucket < ${query.to.toUTC().toJSDate()}`,\n ];\n if (query.labels !== undefined && Object.keys(query.labels).length > 0) {\n conditions.push(sql`labels @> ${JSON.stringify(query.labels)}::jsonb`);\n }\n const { rows } = await sql<SeriesRow>`\n select name, labels, labels_key, source, bucket, count, sum, min, max, last\n from ${sql.table(this.table)}\n where ${this.where(...conditions)}\n order by name, labels_key, bucket\n `.execute(this.db);\n\n const series: MetricSeries[] = [];\n let current: MetricSeries | undefined;\n for (const row of rows) {\n if (current === undefined || current.name !== row.name || current.labelsKey !== row.labels_key) {\n const labels = typeof row.labels === 'string' ? (JSON.parse(row.labels) as MetricLabels) : row.labels;\n current = { name: row.name, labels, labelsKey: row.labels_key, source: row.source, points: [] };\n series.push(current);\n }\n current.points.push({\n start: instantOf(row.bucket),\n aggregate: { count: row.count, sum: row.sum, min: row.min, max: row.max, last: row.last },\n });\n }\n return series;\n }\n\n /**\n * Lists every metric name the store holds, with its source, label keys and newest bucket.\n */\n async names(): Promise<MetricName[]> {\n const { rows } = await sql<NameRow>`\n select name, source, max(bucket) as last_seen, array_agg(distinct key) filter (where key is not null) as label_keys\n from ${sql.table(this.table)} left join lateral jsonb_object_keys(labels) as key on true\n where ${this.where(sql`true`)}\n group by name, source\n order by name, source\n `.execute(this.db);\n return rows.map(row => ({\n name: row.name,\n source: row.source,\n labelKeys: [...(row.label_keys ?? [])].sort(),\n lastSeen: instantOf(row.last_seen),\n }));\n }\n\n /** A column of the row already stored, inside an upsert's `do update`; the insert aliases the table `stored`. */\n private column(name: string): RawBuilder<unknown> {\n return sql.ref(`stored.${name}`);\n }\n\n private conflictTarget(): RawBuilder<unknown> {\n return sql.join([...this.scope.map(([column]) => column), ...PRIMARY_KEY].map(column => sql.ref(column)));\n }\n\n private where(...conditions: RawBuilder<unknown>[]): RawBuilder<unknown> {\n return sql.join([...this.scope.map(([column, value]) => sql`${sql.ref(column)} = ${value}`), ...conditions], sql` and `);\n }\n}\n"],"mappings":"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","names":["sql","DateTime","PRIMARY_KEY","instantOf","value","DateTime","isDateTime","toUTC","Date","fromJSDate","zone","fromISO","replace","MetricsStore","db","table","scope","batchSize","options","withoutPlugins","Object","entries","upsertMinutes","buckets","source","merged","Map","bucket","id","name","labelsKey","start","toMillis","existing","get","set","undefined","aggregate","mergeAggregates","rows","values","map","fromEntries","resolution","toJSDate","labels","JSON","stringify","labels_key","written","at","length","batch","slice","result","sql","join","keys","column","ref","row","conflictTarget","execute","Number","numAffectedRows","rollupHour","hourStart","from","startOf","to","plus","hours","scopeColumns","scopeValues","where","prune","olderThan","now","cutoff","minus","series","query","names","conditions","push","current","parse","points","count","sum","min","max","last","labelKeys","label_keys","sort","lastSeen","last_seen"]}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Five numbers that summarise every observation of one series inside one bucket.
|
|
3
|
+
*
|
|
4
|
+
* One shape covers all three kinds of metric, so a store needs a single row type:
|
|
5
|
+
* - a **gauge** reads `last` (or `sum / count` for the bucket's mean),
|
|
6
|
+
* - a **counter** reads `sum`,
|
|
7
|
+
* - a **duration** reads `sum / count` for the mean and `max` for the worst.
|
|
8
|
+
*
|
|
9
|
+
* There are no percentiles. They need a histogram or a sketch per bucket, and the
|
|
10
|
+
* mean beside the max is enough to see that something got slow.
|
|
11
|
+
*/
|
|
12
|
+
export interface MetricAggregate {
|
|
13
|
+
/** How many observations were folded in. */
|
|
14
|
+
count: number;
|
|
15
|
+
/** Their total. */
|
|
16
|
+
sum: number;
|
|
17
|
+
/** The smallest observation. */
|
|
18
|
+
min: number;
|
|
19
|
+
/** The largest observation. */
|
|
20
|
+
max: number;
|
|
21
|
+
/** The most recent observation. */
|
|
22
|
+
last: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The aggregate of a single observation.
|
|
26
|
+
*
|
|
27
|
+
* @param value - The observed value. The caller is expected to have rejected non-finite values.
|
|
28
|
+
*/
|
|
29
|
+
export declare const aggregateOf: (value: number) => MetricAggregate;
|
|
30
|
+
/**
|
|
31
|
+
* Folds `next` into `previous`.
|
|
32
|
+
*
|
|
33
|
+
* Associative, so buckets can be merged in any grouping (a flush into a stored row, minutes into an
|
|
34
|
+
* hour). Not commutative in `last`: `next` is taken to be the later of the two.
|
|
35
|
+
*
|
|
36
|
+
* @param previous - The earlier aggregate.
|
|
37
|
+
* @param next - The later aggregate.
|
|
38
|
+
* @returns A new aggregate; neither input is modified.
|
|
39
|
+
*/
|
|
40
|
+
export declare const mergeAggregates: (previous: MetricAggregate, next: MetricAggregate) => MetricAggregate;
|
|
41
|
+
//# sourceMappingURL=metric.aggregate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metric.aggregate.d.ts","sourceRoot":"","sources":["../src/metric.aggregate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,MAAM,WAAW,eAAe;IAC9B,4CAA4C;IAC5C,KAAK,EAAE,MAAM,CAAC;IACd,mBAAmB;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,gCAAgC;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,+BAA+B;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,mCAAmC;IACnC,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;GAIG;AACH,eAAO,MAAM,WAAW,GAAI,OAAO,MAAM,KAAG,eAAkF,CAAC;AAE/H;;;;;;;;;GASG;AACH,eAAO,MAAM,eAAe,GAAI,UAAU,eAAe,EAAE,MAAM,eAAe,KAAG,eAMjF,CAAC"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { DateTime } from 'luxon';
|
|
2
|
+
import { MetricAggregate } from './metric.aggregate.js';
|
|
3
|
+
import { MetricLabels } from './metric.labels.js';
|
|
4
|
+
/**
|
|
5
|
+
* One series' aggregate over one minute: what a recorder drains and what a store writes.
|
|
6
|
+
*/
|
|
7
|
+
export interface MetricBucket {
|
|
8
|
+
/** The metric's name, in dot notation: `work.job.ms`. */
|
|
9
|
+
name: string;
|
|
10
|
+
/** The series' labels. */
|
|
11
|
+
labels: MetricLabels;
|
|
12
|
+
/** {@link labelsKey} of `labels`, computed once at observation time. */
|
|
13
|
+
labelsKey: string;
|
|
14
|
+
/** The start of the bucket's minute, in UTC. */
|
|
15
|
+
start: DateTime;
|
|
16
|
+
/** Everything observed for this series in this minute. */
|
|
17
|
+
aggregate: MetricAggregate;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The start of the minute `at` falls in, in UTC.
|
|
21
|
+
*
|
|
22
|
+
* @param at - Any instant.
|
|
23
|
+
*/
|
|
24
|
+
export declare const minuteOf: (at: DateTime) => DateTime;
|
|
25
|
+
//# sourceMappingURL=metric.bucket.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metric.bucket.d.ts","sourceRoot":"","sources":["../src/metric.bucket.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AACjC,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAElD;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,yDAAyD;IACzD,IAAI,EAAE,MAAM,CAAC;IACb,0BAA0B;IAC1B,MAAM,EAAE,YAAY,CAAC;IACrB,wEAAwE;IACxE,SAAS,EAAE,MAAM,CAAC;IAClB,gDAAgD;IAChD,KAAK,EAAE,QAAQ,CAAC;IAChB,0DAA0D;IAC1D,SAAS,EAAE,eAAe,CAAC;CAC5B;AAED;;;;GAIG;AACH,eAAO,MAAM,QAAQ,GAAI,IAAI,QAAQ,KAAG,QAAwC,CAAC"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The labels that tell one series of a metric from another, e.g. `{ queue: 'catalog.sync' }`.
|
|
3
|
+
*
|
|
4
|
+
* Values are strings only. A number used as a label is almost always a cardinality mistake waiting
|
|
5
|
+
* to happen, and making the caller write `String(x)` is a moment to notice it.
|
|
6
|
+
*/
|
|
7
|
+
export type MetricLabels = Readonly<Record<string, string>>;
|
|
8
|
+
/**
|
|
9
|
+
* A canonical string for a label set: JSON with keys sorted.
|
|
10
|
+
*
|
|
11
|
+
* Two label sets with the same pairs in a different order produce the same key, so the key can sit
|
|
12
|
+
* in a primary key where the `jsonb` labels themselves cannot be compared cheaply.
|
|
13
|
+
*
|
|
14
|
+
* @param labels - The label set.
|
|
15
|
+
* @returns `'{}'` for no labels, otherwise sorted JSON.
|
|
16
|
+
*/
|
|
17
|
+
export declare const labelsKey: (labels: MetricLabels) => string;
|
|
18
|
+
//# sourceMappingURL=metric.labels.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metric.labels.d.ts","sourceRoot":"","sources":["../src/metric.labels.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,MAAM,MAAM,YAAY,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;AAE5D;;;;;;;;GAQG;AACH,eAAO,MAAM,SAAS,GAAI,QAAQ,YAAY,KAAG,MAShD,CAAC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { DateTime, Duration } from 'luxon';
|
|
2
|
+
/**
|
|
3
|
+
* The two widths a stored metric row can have: raw minutes, and the hours they are rolled up into.
|
|
4
|
+
*/
|
|
5
|
+
export type MetricResolution = 'minute' | 'hour';
|
|
6
|
+
/**
|
|
7
|
+
* The widest range still read at minute resolution by {@link resolutionFor}: two days is 2,880
|
|
8
|
+
* points per series, about as many as a chart can draw usefully.
|
|
9
|
+
*/
|
|
10
|
+
export declare const MINUTE_RESOLUTION_LIMIT: Duration<true>;
|
|
11
|
+
/**
|
|
12
|
+
* The resolution to read a range at: minutes for a range up to `limit`, hours beyond it.
|
|
13
|
+
*
|
|
14
|
+
* @param from - The start of the range.
|
|
15
|
+
* @param to - The end of the range.
|
|
16
|
+
* @param limit - The widest range read in minutes. Defaults to {@link MINUTE_RESOLUTION_LIMIT}.
|
|
17
|
+
*/
|
|
18
|
+
export declare const resolutionFor: (from: DateTime, to: DateTime, limit?: Duration) => MetricResolution;
|
|
19
|
+
//# sourceMappingURL=metric.resolution.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metric.resolution.d.ts","sourceRoot":"","sources":["../src/metric.resolution.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAE3C;;GAEG;AACH,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,MAAM,CAAC;AAEjD;;;GAGG;AACH,eAAO,MAAM,uBAAuB,gBAAqC,CAAC;AAE1E;;;;;;GAMG;AACH,eAAO,MAAM,aAAa,GAAI,MAAM,QAAQ,EAAE,IAAI,QAAQ,EAAE,QAAO,QAAkC,KAAG,gBAC5D,CAAC"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { DateTime, Duration } from 'luxon';
|
|
2
|
+
import { MetricBucket } from './metric.bucket.js';
|
|
3
|
+
import { MetricLabels } from './metric.labels.js';
|
|
4
|
+
/**
|
|
5
|
+
* Reads a gauge's current value. `undefined` means "no reading this time" and records nothing.
|
|
6
|
+
*/
|
|
7
|
+
export type GaugeReader = () => number | undefined | Promise<number | undefined>;
|
|
8
|
+
/**
|
|
9
|
+
* Called when a gauge's reader throws. The sample is skipped either way.
|
|
10
|
+
*/
|
|
11
|
+
export type GaugeErrorHandler = (name: string, error: unknown) => void;
|
|
12
|
+
/**
|
|
13
|
+
* How a gauge is registered.
|
|
14
|
+
*/
|
|
15
|
+
export interface GaugeOptions {
|
|
16
|
+
/** Labels for the gauge's series. Defaults to none. */
|
|
17
|
+
labels?: MetricLabels;
|
|
18
|
+
/**
|
|
19
|
+
* The least time between two samples. Defaults to zero: sampled on every
|
|
20
|
+
* {@link MetricsRecorder.sampleGauges} call. Use it for a reading that is expensive (a disk scan)
|
|
21
|
+
* while the sampler runs every minute for the cheap ones.
|
|
22
|
+
*/
|
|
23
|
+
every?: Duration;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Accumulates observations into one-minute buckets in memory, and samples registered gauges.
|
|
27
|
+
*
|
|
28
|
+
* Nothing here does I/O. The owner runs a loop that calls {@link sampleGauges} and then
|
|
29
|
+
* {@link drain}, and writes what it drained wherever it keeps metrics (the `/kysely` store, an OTLP
|
|
30
|
+
* bridge, both). Draining hands over the current, still-open minute too: a store merges a later
|
|
31
|
+
* flush of the same minute into the row already written, so nothing waits for a minute to close.
|
|
32
|
+
*
|
|
33
|
+
* Register it as a singleton. One instance per process is the point: every producer observes into
|
|
34
|
+
* the same buffer.
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```typescript
|
|
38
|
+
* const recorder = container.get(MetricsRecorder);
|
|
39
|
+
* recorder.observe('work.job.ms', 412, { queue: 'catalog.sync' });
|
|
40
|
+
* recorder.gauge('station.listeners', () => audience.count());
|
|
41
|
+
*
|
|
42
|
+
* await recorder.sampleGauges();
|
|
43
|
+
* await store.upsertMinutes(recorder.drain());
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export declare class MetricsRecorder {
|
|
47
|
+
private open;
|
|
48
|
+
private readonly gauges;
|
|
49
|
+
/**
|
|
50
|
+
* Told about a gauge whose reader threw. Unset, such failures are silent: a gauge that cannot be
|
|
51
|
+
* read simply leaves a gap, which is what a chart should show.
|
|
52
|
+
*/
|
|
53
|
+
onGaugeError?: GaugeErrorHandler;
|
|
54
|
+
/**
|
|
55
|
+
* Records one observation into the minute `at` falls in.
|
|
56
|
+
*
|
|
57
|
+
* Non-finite values (`NaN`, `Infinity`) are dropped: one of them would poison every aggregate
|
|
58
|
+
* of the bucket it lands in.
|
|
59
|
+
*
|
|
60
|
+
* @param name - The metric's name.
|
|
61
|
+
* @param value - The observed value.
|
|
62
|
+
* @param labels - The series' labels.
|
|
63
|
+
* @param at - When it was observed. Defaults to now.
|
|
64
|
+
*/
|
|
65
|
+
observe(name: string, value: number, labels?: MetricLabels, at?: DateTime): void;
|
|
66
|
+
/**
|
|
67
|
+
* Registers a gauge to be sampled by {@link sampleGauges}.
|
|
68
|
+
*
|
|
69
|
+
* Registering the same name and labels again replaces the earlier reader.
|
|
70
|
+
*
|
|
71
|
+
* @param name - The metric's name.
|
|
72
|
+
* @param read - Returns the current value.
|
|
73
|
+
* @param options - Labels and sampling cadence.
|
|
74
|
+
* @returns A function that unregisters this gauge.
|
|
75
|
+
*/
|
|
76
|
+
gauge(name: string, read: GaugeReader, options?: GaugeOptions): () => void;
|
|
77
|
+
/**
|
|
78
|
+
* Reads every gauge that is due and observes its value at `now`.
|
|
79
|
+
*
|
|
80
|
+
* Readers run concurrently, so one slow gauge does not delay the others. A reader that throws
|
|
81
|
+
* or answers `undefined` records nothing; it is still marked sampled, so a failing reader is
|
|
82
|
+
* retried at its own cadence rather than on every call.
|
|
83
|
+
*
|
|
84
|
+
* @param now - The sampling instant. Defaults to now.
|
|
85
|
+
*/
|
|
86
|
+
sampleGauges(now?: DateTime): Promise<void>;
|
|
87
|
+
/**
|
|
88
|
+
* Hands over every bucket recorded since the last drain and empties the buffer.
|
|
89
|
+
*
|
|
90
|
+
* @returns The buckets, oldest minute first.
|
|
91
|
+
*/
|
|
92
|
+
drain(): MetricBucket[];
|
|
93
|
+
/** How many buckets are waiting to be drained. */
|
|
94
|
+
get pending(): number;
|
|
95
|
+
}
|
|
96
|
+
//# sourceMappingURL=metrics.recorder.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metrics.recorder.d.ts","sourceRoot":"","sources":["../src/metrics.recorder.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAE3C,OAAO,EAAE,YAAY,EAAY,MAAM,oBAAoB,CAAC;AAC5D,OAAO,EAAa,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAE7D;;GAEG;AACH,MAAM,MAAM,WAAW,GAAG,MAAM,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;AAEjF;;GAEG;AACH,MAAM,MAAM,iBAAiB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;AAEvE;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,uDAAuD;IACvD,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB;;;;OAIG;IACH,KAAK,CAAC,EAAE,QAAQ,CAAC;CAClB;AAUD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,qBACa,eAAe;IAC1B,OAAO,CAAC,IAAI,CAAmC;IAC/C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAsC;IAE7D;;;OAGG;IACH,YAAY,CAAC,EAAE,iBAAiB,CAAC;IAEjC;;;;;;;;;;OAUG;IACH,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,GAAE,YAAiB,EAAE,EAAE,GAAE,QAAyB,GAAG,IAAI;IAepG;;;;;;;;;OASG;IACH,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,EAAE,OAAO,GAAE,YAAiB,GAAG,MAAM,IAAI;IAY9E;;;;;;;;OAQG;IACG,YAAY,CAAC,GAAG,GAAE,QAAyB,GAAG,OAAO,CAAC,IAAI,CAAC;IAiBjE;;;;OAIG;IACH,KAAK,IAAI,YAAY,EAAE;IAMvB,kDAAkD;IAClD,IAAI,OAAO,IAAI,MAAM,CAEpB;CACF"}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Kysely } from 'kysely';
|
|
2
|
+
import { DateTime, Duration } from 'luxon';
|
|
3
|
+
import { MetricAggregate } from './metric.aggregate.js';
|
|
4
|
+
import { MetricBucket } from './metric.bucket.js';
|
|
5
|
+
import { MetricLabels } from './metric.labels.js';
|
|
6
|
+
import { MetricResolution } from './metric.resolution.js';
|
|
7
|
+
/**
|
|
8
|
+
* Where a {@link MetricsStore} keeps its rows.
|
|
9
|
+
*/
|
|
10
|
+
export interface MetricsStoreOptions {
|
|
11
|
+
/** The table, schema-qualified if needed: `'metrics.metric_samples'`. */
|
|
12
|
+
table: string;
|
|
13
|
+
/**
|
|
14
|
+
* Fixed column values every row carries and every read filters on, e.g. `{ tenant_key: 'main' }`
|
|
15
|
+
* for an app that keeps several tenants in one table. Each column must be part of the table's
|
|
16
|
+
* primary key. Column names are written as they are in the database.
|
|
17
|
+
*/
|
|
18
|
+
scope?: Readonly<Record<string, string>>;
|
|
19
|
+
/** Rows per `insert` statement when upserting. Defaults to 500. */
|
|
20
|
+
batchSize?: number;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* One stored point of a series.
|
|
24
|
+
*/
|
|
25
|
+
export interface MetricPoint {
|
|
26
|
+
/** The start of the point's minute or hour, in UTC. */
|
|
27
|
+
start: DateTime;
|
|
28
|
+
/** The point's aggregate. */
|
|
29
|
+
aggregate: MetricAggregate;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* One series read back from the store, its points oldest first.
|
|
33
|
+
*/
|
|
34
|
+
export interface MetricSeries {
|
|
35
|
+
name: string;
|
|
36
|
+
labels: MetricLabels;
|
|
37
|
+
labelsKey: string;
|
|
38
|
+
source: string;
|
|
39
|
+
points: MetricPoint[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* What to read with {@link MetricsStore.series}.
|
|
43
|
+
*/
|
|
44
|
+
export interface MetricSeriesQuery {
|
|
45
|
+
/** The metric names to read. An empty list reads nothing. */
|
|
46
|
+
names: readonly string[];
|
|
47
|
+
/** Inclusive start. */
|
|
48
|
+
from: DateTime;
|
|
49
|
+
/** Exclusive end. */
|
|
50
|
+
to: DateTime;
|
|
51
|
+
/** Which rows to read. See `resolutionFor`. */
|
|
52
|
+
resolution: MetricResolution;
|
|
53
|
+
/** Only series whose labels contain all of these pairs. */
|
|
54
|
+
labels?: MetricLabels;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A metric name the store holds, for building a picker.
|
|
58
|
+
*/
|
|
59
|
+
export interface MetricName {
|
|
60
|
+
name: string;
|
|
61
|
+
source: string;
|
|
62
|
+
/** Every label key any of its series carries, sorted. */
|
|
63
|
+
labelKeys: string[];
|
|
64
|
+
/** The newest bucket stored for it, at any resolution. */
|
|
65
|
+
lastSeen: DateTime;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Keeps metric buckets in one PostgreSQL table at two resolutions, minutes and hours.
|
|
69
|
+
*
|
|
70
|
+
* The table is the app's own, created by its own migration from the reference DDL in this package's
|
|
71
|
+
* README. The store only reads and writes it.
|
|
72
|
+
*
|
|
73
|
+
* It runs on `db.withoutPlugins()`, so the app's `CamelCasePlugin` (or any other) never rewrites the
|
|
74
|
+
* snake_case columns this class names, and rows come back exactly as PostgreSQL sends them.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```typescript
|
|
78
|
+
* const store = new MetricsStore(db, { table: 'app.metric_samples' });
|
|
79
|
+
* await store.upsertMinutes(recorder.drain());
|
|
80
|
+
* await store.rollupHour(DateTime.utc().startOf('hour').minus({ hours: 1 }));
|
|
81
|
+
* ```
|
|
82
|
+
*
|
|
83
|
+
* @typeParam DB - The app's database type. The store never uses it (every statement is raw SQL on the
|
|
84
|
+
* table it was given), so any `Kysely<DB>` is accepted as it is, with no cast at the call site.
|
|
85
|
+
*/
|
|
86
|
+
export declare class MetricsStore<DB = unknown> {
|
|
87
|
+
private readonly db;
|
|
88
|
+
private readonly table;
|
|
89
|
+
private readonly scope;
|
|
90
|
+
private readonly batchSize;
|
|
91
|
+
/**
|
|
92
|
+
* @param db - Any Kysely instance on the database holding the table.
|
|
93
|
+
* @param options - The table and scope.
|
|
94
|
+
*/
|
|
95
|
+
constructor(db: Kysely<DB>, options: MetricsStoreOptions);
|
|
96
|
+
/**
|
|
97
|
+
* Writes minute buckets, merging each into the row already stored for its series and minute.
|
|
98
|
+
*
|
|
99
|
+
* A minute flushed twice therefore adds up rather than failing or being overwritten, which is what
|
|
100
|
+
* lets a recorder drain its still-open minute. Buckets for the same series and minute in one call
|
|
101
|
+
* are merged first, since PostgreSQL refuses to update one row twice in a statement.
|
|
102
|
+
*
|
|
103
|
+
* @param buckets - What a recorder drained.
|
|
104
|
+
* @param source - Recorded on each row; tells an app's own metrics from received ones. Defaults to `'app'`.
|
|
105
|
+
* @returns How many rows were written.
|
|
106
|
+
*/
|
|
107
|
+
upsertMinutes(buckets: readonly MetricBucket[], source?: string): Promise<number>;
|
|
108
|
+
/**
|
|
109
|
+
* Folds one hour of minute rows into hour rows.
|
|
110
|
+
*
|
|
111
|
+
* Idempotent: the hour row is replaced, not added to, so running it twice (a retried job, an
|
|
112
|
+
* overlapping schedule) leaves the same answer. Run it once the hour is complete; run before then
|
|
113
|
+
* and a later run corrects it.
|
|
114
|
+
*
|
|
115
|
+
* @param hourStart - Any instant inside the hour to roll up; it is truncated to the UTC hour.
|
|
116
|
+
* @returns How many hour rows were written.
|
|
117
|
+
*/
|
|
118
|
+
rollupHour(hourStart: DateTime): Promise<number>;
|
|
119
|
+
/**
|
|
120
|
+
* Deletes rows of one resolution older than `olderThan`.
|
|
121
|
+
*
|
|
122
|
+
* @param resolution - Which rows.
|
|
123
|
+
* @param olderThan - How old a row's bucket must be to go.
|
|
124
|
+
* @param now - The instant ages are measured from. Defaults to now.
|
|
125
|
+
* @returns How many rows were deleted.
|
|
126
|
+
*/
|
|
127
|
+
prune(resolution: MetricResolution, olderThan: Duration, now?: DateTime): Promise<number>;
|
|
128
|
+
/**
|
|
129
|
+
* Reads series over a range, one entry per name and label set, points oldest first.
|
|
130
|
+
*
|
|
131
|
+
* @param query - The names, range, resolution and label filter.
|
|
132
|
+
*/
|
|
133
|
+
series(query: MetricSeriesQuery): Promise<MetricSeries[]>;
|
|
134
|
+
/**
|
|
135
|
+
* Lists every metric name the store holds, with its source, label keys and newest bucket.
|
|
136
|
+
*/
|
|
137
|
+
names(): Promise<MetricName[]>;
|
|
138
|
+
/** A column of the row already stored, inside an upsert's `do update`; the insert aliases the table `stored`. */
|
|
139
|
+
private column;
|
|
140
|
+
private conflictTarget;
|
|
141
|
+
private where;
|
|
142
|
+
}
|
|
143
|
+
//# sourceMappingURL=metrics.store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metrics.store.d.ts","sourceRoot":"","sources":["../src/metrics.store.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAmB,MAAM,QAAQ,CAAC;AACjD,OAAO,EAAE,QAAQ,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAC3C,OAAO,EAAmB,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACzE,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;AAE1D;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC,yEAAyE;IACzE,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,KAAK,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACzC,mEAAmE;IACnE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,uDAAuD;IACvD,KAAK,EAAE,QAAQ,CAAC;IAChB,6BAA6B;IAC7B,SAAS,EAAE,eAAe,CAAC;CAC5B;AAED;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,YAAY,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,WAAW,EAAE,CAAC;CACvB;AAED;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,6DAA6D;IAC7D,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;IACzB,uBAAuB;IACvB,IAAI,EAAE,QAAQ,CAAC;IACf,qBAAqB;IACrB,EAAE,EAAE,QAAQ,CAAC;IACb,+CAA+C;IAC/C,UAAU,EAAE,gBAAgB,CAAC;IAC7B,2DAA2D;IAC3D,MAAM,CAAC,EAAE,YAAY,CAAC;CACvB;AAED;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,yDAAyD;IACzD,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,0DAA0D;IAC1D,QAAQ,EAAE,QAAQ,CAAC;CACpB;AAuCD;;;;;;;;;;;;;;;;;;GAkBG;AACH,qBAAa,YAAY,CAAC,EAAE,GAAG,OAAO;IACpC,OAAO,CAAC,QAAQ,CAAC,EAAE,CAAa;IAChC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAqB;IAC3C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IAEnC;;;OAGG;gBACS,EAAE,EAAE,MAAM,CAAC,EAAE,CAAC,EAAE,OAAO,EAAE,mBAAmB;IAOxD;;;;;;;;;;OAUG;IACG,aAAa,CAAC,OAAO,EAAE,SAAS,YAAY,EAAE,EAAE,MAAM,SAAQ,GAAG,OAAO,CAAC,MAAM,CAAC;IAoCtF;;;;;;;;;OASG;IACG,UAAU,CAAC,SAAS,EAAE,QAAQ,GAAG,OAAO,CAAC,MAAM,CAAC;IAoBtD;;;;;;;OAOG;IACG,KAAK,CAAC,UAAU,EAAE,gBAAgB,EAAE,SAAS,EAAE,QAAQ,EAAE,GAAG,GAAE,QAAyB,GAAG,OAAO,CAAC,MAAM,CAAC;IAS/G;;;;OAIG;IACG,MAAM,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;IAoC/D;;OAEG;IACG,KAAK,IAAI,OAAO,CAAC,UAAU,EAAE,CAAC;IAgBpC,iHAAiH;IACjH,OAAO,CAAC,MAAM;IAId,OAAO,CAAC,cAAc;IAItB,OAAO,CAAC,KAAK;CAGd"}
|
package/dist/otlp.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"otlp.d.ts","sourceRoot":"","sources":["../src/otlp.ts"],"names":[],"mappings":"AAAA,cAAc,eAAe,CAAC;AAC9B,cAAc,0BAA0B,CAAC;AACzC,cAAc,uBAAuB,CAAC"}
|