@maroonedsoftware/telemetry 0.1.0 → 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 +8 -8
- package/dist/kysely.js +13 -5
- package/dist/kysely.js.map +1 -1
- package/dist/metrics.store.d.ts +5 -2
- package/dist/metrics.store.d.ts.map +1 -1
- package/package.json +13 -13
- package/dist/chunk-QS6DX2UZ.js +0 -24
- package/dist/chunk-SHUYVCID.js +0 -6
- package/dist/chunk-Z5AB6WWF.js +0 -24
package/AGENTS.md
CHANGED
|
@@ -56,14 +56,14 @@ Optional peers: `kysely` — unlocks the `./kysely` subpath (`MetricsStore`). `@
|
|
|
56
56
|
|
|
57
57
|
### `./kysely`
|
|
58
58
|
|
|
59
|
-
| Export | Kind | Shape
|
|
60
|
-
| --------------------- | --------- |
|
|
61
|
-
| `MetricsStore
|
|
62
|
-
| `MetricsStoreOptions` | interface | `{ table: string; scope?: Record<string, string>; batchSize?: number }`
|
|
63
|
-
| `MetricSeriesQuery` | interface | `{ names, from, to, resolution, labels? }`
|
|
64
|
-
| `MetricSeries` | interface | `{ name, labels, labelsKey, source, points: MetricPoint[] }`
|
|
65
|
-
| `MetricPoint` | interface | `{ start: DateTime; aggregate: MetricAggregate }`
|
|
66
|
-
| `MetricName` | interface | `{ name, source, labelKeys: string[], lastSeen: DateTime }`
|
|
59
|
+
| Export | Kind | Shape | Notes |
|
|
60
|
+
| --------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
|
|
61
|
+
| `MetricsStore<DB>` | class | `new MetricsStore(db: Kysely<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
67
|
|
|
68
68
|
### `./otlp`
|
|
69
69
|
|
package/dist/kysely.js
CHANGED
|
@@ -14,11 +14,19 @@ var PRIMARY_KEY = [
|
|
|
14
14
|
"labels_key",
|
|
15
15
|
"bucket"
|
|
16
16
|
];
|
|
17
|
-
var instantOf = /* @__PURE__ */ __name((value) =>
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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");
|
|
22
30
|
var MetricsStore = class {
|
|
23
31
|
static {
|
|
24
32
|
__name(this, "MetricsStore");
|
package/dist/kysely.js.map
CHANGED
|
@@ -1 +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: 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: Date | string;\n label_keys: string[] | null;\n}\n\nconst PRIMARY_KEY = ['resolution', 'name', 'labels_key', 'bucket'] as const;\n\n/** Reads a `timestamptz` the way `pg` hands it back: a `Date`, or text under some type parsers. */\nconst instantOf = (value: Date | string): DateTime =>\n value instanceof Date ? DateTime.fromJSDate(value, { zone: 'utc' }) : DateTime.fromISO(value.replace(' ', 'T'), { zone: 'utc' });\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 */\nexport class MetricsStore {\n private readonly db: Kysely<unknown>;\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<unknown>, 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","Date","DateTime","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","toUTC","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"]}
|
|
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"]}
|
package/dist/metrics.store.d.ts
CHANGED
|
@@ -79,8 +79,11 @@ export interface MetricName {
|
|
|
79
79
|
* await store.upsertMinutes(recorder.drain());
|
|
80
80
|
* await store.rollupHour(DateTime.utc().startOf('hour').minus({ hours: 1 }));
|
|
81
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.
|
|
82
85
|
*/
|
|
83
|
-
export declare class MetricsStore {
|
|
86
|
+
export declare class MetricsStore<DB = unknown> {
|
|
84
87
|
private readonly db;
|
|
85
88
|
private readonly table;
|
|
86
89
|
private readonly scope;
|
|
@@ -89,7 +92,7 @@ export declare class MetricsStore {
|
|
|
89
92
|
* @param db - Any Kysely instance on the database holding the table.
|
|
90
93
|
* @param options - The table and scope.
|
|
91
94
|
*/
|
|
92
|
-
constructor(db: Kysely<
|
|
95
|
+
constructor(db: Kysely<DB>, options: MetricsStoreOptions);
|
|
93
96
|
/**
|
|
94
97
|
* Writes minute buckets, merging each into the row already stored for its series and minute.
|
|
95
98
|
*
|
|
@@ -1 +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;
|
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maroonedsoftware/telemetry",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Minute-bucket metrics for ServerKit: an in-process recorder and gauge registry, a Kysely store with hourly rollups, and OpenTelemetry bridges.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Marooned Software",
|
|
@@ -48,14 +48,6 @@
|
|
|
48
48
|
"AGENTS.md",
|
|
49
49
|
"dist/**"
|
|
50
50
|
],
|
|
51
|
-
"scripts": {
|
|
52
|
-
"build": "tsup src/index.ts src/kysely.ts src/otlp.ts --format esm --sourcemap && tsc --emitDeclarationOnly --declaration",
|
|
53
|
-
"build:ci": "eslint --max-warnings=0 && pnpm run build",
|
|
54
|
-
"lint": "eslint --fix",
|
|
55
|
-
"format": "prettier --write .",
|
|
56
|
-
"test": "vitest run",
|
|
57
|
-
"test:ci": "vitest run --coverage"
|
|
58
|
-
},
|
|
59
51
|
"dependencies": {
|
|
60
52
|
"injectkit": "^1.7.1",
|
|
61
53
|
"luxon": "^3.7.2"
|
|
@@ -95,12 +87,20 @@
|
|
|
95
87
|
"@opentelemetry/resources": "^2.12.0",
|
|
96
88
|
"@opentelemetry/sdk-metrics": "^2.12.0",
|
|
97
89
|
"@opentelemetry/sdk-trace": "^2.12.0",
|
|
98
|
-
"@repo/config-eslint": "workspace:*",
|
|
99
|
-
"@repo/config-typescript": "workspace:*",
|
|
100
90
|
"@types/luxon": "^3.7.5",
|
|
101
|
-
"kysely": "^0.29.5"
|
|
91
|
+
"kysely": "^0.29.5",
|
|
92
|
+
"@repo/config-eslint": "0.2.1",
|
|
93
|
+
"@repo/config-typescript": "0.1.0"
|
|
102
94
|
},
|
|
103
95
|
"engines": {
|
|
104
96
|
"node": ">=22"
|
|
97
|
+
},
|
|
98
|
+
"scripts": {
|
|
99
|
+
"build": "tsup src/index.ts src/kysely.ts src/otlp.ts --format esm --sourcemap && tsc --emitDeclarationOnly --declaration",
|
|
100
|
+
"build:ci": "eslint --max-warnings=0 && pnpm run build",
|
|
101
|
+
"lint": "eslint --fix",
|
|
102
|
+
"format": "prettier --write .",
|
|
103
|
+
"test": "vitest run",
|
|
104
|
+
"test:ci": "vitest run --coverage"
|
|
105
105
|
}
|
|
106
|
-
}
|
|
106
|
+
}
|
package/dist/chunk-QS6DX2UZ.js
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
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
|
-
};
|
package/dist/chunk-SHUYVCID.js
DELETED
package/dist/chunk-Z5AB6WWF.js
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
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
|
-
};
|