@adriangalilea/utils 2.38.1 → 4.0.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/README.md CHANGED
@@ -2,6 +2,57 @@
2
2
 
3
3
  TypeScript utilities - logger, currency, offensive programming, file operations, environment management, and more. The sibling of [swift-utils](https://github.com/adriangalilea/swift-utils), [py-utils](https://github.com/adriangalilea/py-utils), and [go-utils](https://github.com/adriangalilea/go-utils). The logging doctrine the three share, and the harness that proves they emit the same record line, live in [utils](https://github.com/adriangalilea/utils).
4
4
 
5
+ ## Product metrics
6
+
7
+ ```ts
8
+ import { createClient } from "@libsql/client"
9
+ import { defineMetrics } from "@adriangalilea/utils/metrics"
10
+ import { sqliteMetricsWriter } from "@adriangalilea/utils/metrics/sqlite"
11
+
12
+ const db = createClient({ url: process.env.METRICS_DATABASE_URL!, authToken: process.env.METRICS_AUTH_TOKEN })
13
+ const metrics = defineMetrics({
14
+ installCopy: { kind: "counter", label: "install command copies", dimensions: { component: ["chat", "glass"] } },
15
+ }, { write: sqliteMetricsWriter(db, "my-project") })
16
+
17
+ await metrics.installCopy.bump({ dimensions: { component: "chat" } })
18
+ ```
19
+
20
+ Provision the exported `METRICS_SCHEMA` once before collecting. `describe()` returns
21
+ the declarations; the SQL writer stores labels beside daily counters so reports
22
+ discover metrics without another hardcoded list. `readMetrics(db, { from, to,
23
+ project? })` returns daily rows with labels and dimensions, using inclusive UTC dates.
24
+ Readers propagate database errors rather than presenting missing data as zero.
25
+ `summarizeMetrics(rows)` from `metrics/report` produces one generic report shape;
26
+ `renderMetrics(rows, { daily? })` from `metrics/cli` renders it with the existing
27
+ ANSI-aware `cli.table` helpers. Both discover every recorded metric from its
28
+ declaration metadata. No product-specific metric list belongs in a renderer.
29
+ The SQL adapter accepts a libSQL-compatible client; D1 or another store can implement
30
+ the injected `write(measurement)` contract instead. No database client is bundled.
31
+
32
+ Measurements validate finite nonnegative samples, positive integer counts, and
33
+ declared dimension values. Calls resolve even when validation or storage fails;
34
+ `onError(error, key)` observes failures (the default logs a warning without actor
35
+ data). Await calls or attach them to the runtime's request lifetime (`after` in
36
+ Next.js, `waitUntil` in Workers). Unattached promises are not reliable delivery.
37
+ Counters are best-effort observations, not exactly-once ledgers; retries can count
38
+ twice. Money and successful-install accounting belong elsewhere.
39
+
40
+ `perUser: true` accepts an optional string or numeric `user` for daily actor rows.
41
+ Enable it only with a deletion and retention policy; no identity is inferred.
42
+ `overlaps: [["a", "b"]]` declares unordered audience overlap, not an ordered funnel.
43
+ Daily totals support counts and averages, not percentile latency or event ordering.
44
+
45
+ **4.0 migration:** import `DailyMetric` from `metrics/report`, not `metrics/sqlite`.
46
+ Report types and renderers are store-independent; SQLite is one optional adapter.
47
+ This removes the old type export rather than retaining a compatibility alias.
48
+
49
+ **3.0 migration:** `bot/metrics` and its bot-barrel export are removed. Import
50
+ `@adriangalilea/utils/metrics`; replace `write(key, count, sum, user)` with
51
+ `write({ key, count, sum, user, day, dimensions, spec })`. Rename `funnels` to
52
+ `overlaps` in declarations and schema consumers. Existing stores can adapt the new
53
+ write object without moving historical data. Metric keys and dimensions are storage
54
+ identifiers: introduce a new key when changing meaning rather than relabelling history.
55
+
5
56
  ## Installation
6
57
 
7
58
  ```bash
@@ -611,7 +662,8 @@ Telegram's [Threaded Mode](https://telegram.org/blog/threaded-conversations) for
611
662
  | `@adriangalilea/utils/bot/coalesce` | Joins client-split inbound messages back into one. When a user pastes >4096 chars, Telegram clients fragment it into separate `message` updates with no marker. Middleware detects the burst and emits one combined event. |
612
663
  | `@adriangalilea/utils/llm` | Multi-provider LLM caller on the Vercel AI SDK — the policy layer the SDK doesn't ship. `createLlm({ providers, health? })` → `.stream(req)` (typed events: `delta` / `reasoning` / `reset` / `tool-call` / `end`) and `.complete(req)`. **Failover:** priority-ordered across OpenAI-compatible, Anthropic-compatible, and OpenRouter endpoints; multiple keys per provider (health tracked per key fingerprint); per-key circuit breaker with exponential cool-down persisted in any KV-shaped `HealthStore`; an attempt that dies after emitting yields `reset` (discard, next candidate regenerates); empty completions count as failures. **Accounting:** tokens summed across billed attempts; `costUsd` is the ACTUAL charge (OpenRouter usage accounting via `providerMetadata`, or a `cost` field on the raw usage frame) — never a price-table estimate. **Knobs:** per-model temperature, maxTokens caps, `disableThinking` mapped to each dialect. **Tools:** AI SDK `tool()` + `toolChoice` pass through (re-exported). Worker-safe; peers: `ai`, `@ai-sdk/openai-compatible`, `@ai-sdk/anthropic`, `@openrouter/ai-sdk-provider`. |
613
664
  | `@adriangalilea/utils/bot/llm` | The Telegram side of an LLM chatbot; the model side is `@adriangalilea/utils/llm`. **Output:** `streamChatReply(ctx, events, opts?)` consumes an `AsyncIterable<LlmStreamEvent>` and paints it with Telegram's native message-draft streaming (`sendMessageDraft` full-frame repaints, throttled ~1/s, keepalive under the ~30s draft TTL), then persists the finished markdown via `ctx.send`, entity-split across 4096 by `@gramio/split`. Reasoning models get a thinking phase — streams into the ephemeral draft and evaporates (`reasoning: 'preview'`, default), persists as an expandable blockquote (`'message'`), or never renders at all (`'hidden'`). Upstream `reset` (provider failover) repaints the draft from scratch. Drafts are private-chat-only (+ BotFather forum-topic mode); elsewhere the preview phase is skipped and only the final send happens. Returns `{ content, reasoning, toolCalls, usage, messages }`. The painter underneath is `@adriangalilea/utils/bot/draft` (`createDraftPreview(ctx, { render, throttleMs? })`): dependency-light (no markdown machinery), for producers that push deltas or own their per-frame rendering (plain or rich-message frames) and persist path, with `streamForensics` (render-regression + reset warnings) built in and exported standalone. **History:** `llmHistory({...})` returns `.plugin` (decorates `ctx.llm` with `.add() / .get() / .clear() / .all() / .clearAll()`, per-(user, thread) OpenAI `ChatMessage` shape, persisted in the shared session record so the menu's 🗑 Forget wipes it automatically) AND `.menuItem` (drop-in "🗑 Delete this thread" for `botMenu` — wipes the LLM history AND calls `deleteForumTopic` so the Telegram thread + all its messages disappear from the chat; falls back to history-only clear when no `threadId` is present). |
614
- | `@adriangalilea/utils/bot/metrics` | Self-serve product measurement, the flags pattern applied to counting: `defineMetrics(spec, { write, funnels? })` — declare a metric once (counter / timing, `perUser: true` for uniques·repeat·retention·funnels), `bump()`/`record()` at the call site (fire-and-forget, never throws), `describe()` is the schema panels render generically so a new metric reaches every panel with zero panel edits. Storage = one injected atomic day-bucket upsert (aggregate row + per-user row when `user` given); per-user rows are personal data — wipe on forget, prune by age. Money stays in ledgers. |
665
+ | `@adriangalilea/utils/metrics` | Declared counters and timings, bounded dimensions, optional actor IDs, and a schema for generic reporting. Inject a writer; measurement failures are reported without rejecting calls. See Product metrics below. |
666
+ | `@adriangalilea/utils/metrics/sqlite` | Atomic SQLite/libSQL writer, explicit provisioning schema, and inclusive UTC daily reports. Connections and credentials belong to the application. |
615
667
  | `@adriangalilea/utils/bot/menu` | `botMenu({ command, description, items, privacy?, personalData?, adminContact })` — `/settings` command + InlineKeyboard router. Root view always renders a `🛡️ Privacy & data` submenu button that wraps the privacy policy link plus (if `personalData: { storage }`) 🗑 Forget + 📥 Export buttons. Items take `keepRow` (render on the same row as the next item — e.g. a two-per-row language picker) and `rootExtra` (render at the bottom of the root menu, below Privacy & data). `label` / `header` / `style` / `visible` resolvers may be **async** (read your db at render time — never cache render strings in the session); `parseMode: 'HTML'` renders the header formatted (you own escaping); `personalData.onForget(ctx, userId)` wipes YOUR tables inside the same try as the session delete, so Forget either forgets everything or reports failure. `toggleMenuItem({ id, read, write, label: { off, on }, toast? })` — convenience factory for boolean-toggle items with dynamic label + optional toast, storage-agnostic via `read`/`write` closures. |
616
668
  | `@adriangalilea/utils/bot/payments` | `botPayments({ session, storage, paysupport, paysupportHint?, legal, waiver, vip?, credits?, perks? })` — Telegram Stars monetization in one drop-in plugin. **Three axes, all optional:** `vip` (positional tier ladder — single rung in v1 is just `vip: [{...}]`, ladder is `vip: [{...}, {...}]`; ids are `vip.1`, `vip.2`, …), `credits` (consumable balance + top-up packs `credits.1`, `credits.2`, …), `perks` (orthogonal one-shot unlocks `perks.<key>`). **Surface:** `ctx.payments.atLeast('vip')` / `atLeast('vip.2')` (typed rank check), `ctx.payments.tier()` / `.tier.level()` / `.tier.label()`, `ctx.payments.credits.{balance, consume, tryConsume}` (throws `InsufficientCredits`), `ctx.payments.has(perkId)`, `await ctx.payments.require('vip', { feature? })` (gate that sends a localized upgrade prompt deep-linked to `/settings → 💎 VIP`), `await ctx.payments.invoice(productKey)` (threads Art. 103(m) TRLGDCU consent inline before `sendInvoice`). **Owns:** waiver consent flow (versioned text → forces re-consent on bump, snapshotted on every charge for audit), `/paysupport` slash command (Telegram ToS §6.5; `paysupportHint` overrides the where-to-manage-charges line when your menu isn't `/settings`), idempotent `successful_payment` fulfillment via `pay:idempotency:{chargeId}` sentinel, lazy subscription expiry (no cron needed), tier upgrade auto-cancel of the lower rung's renewal, and admin-DM refund approval (mirror of `accessControl`'s [✅ Aprobar][❌ Denegar] pattern). **Returns:** `{ plugin, menuItem, payouts, onFulfilled }` — `menuItem` is the drop-in `💎 VIP` entry for `botMenu`; `payouts.{record, list, export, exportForUsers}` is the Fragment payout ledger (you receive TON, log the EUR conversion, export time-windowed CSV/JSON for your gestor); `onFulfilled(productKey \| '*', handler)` / `onRefunded(...)` register fire-and-forget hooks (purchase applied / admin-approved refund — a revenue ledger writes on one, reverses on the other). **Stars-only by design** — Telegram ToS §6.2 forbids third-party payment providers for digital goods. Crypto Pay deferred (MiCA risk); Stripe-outside-Telegram is a future v2 channel. Full compliance memo (Spanish-autónomo seller-of-record analysis, Verifactu vs Crea y Crece, MiCA, Art. 103(m) waiver text, GDPR retention) in `src/bot/payments/CLAUDE.md`. |
617
669
  | `@adriangalilea/utils/bot/create` | `createBot<S>({ token?, storage?, initial?, admins?, language?, menu?, access?, payments?, handlers?, worker? })` — the composer (see "One bot file, ideation → production" above). Returns `{ build, session, poll, isMain, fetch }`: `poll()` long-polls, `export default app` is a complete Worker, `app.session(ctx)` / the `handlers` callback's `session(ctx)` is the TYPED accessor for your `S` fields. Owns storage+session wiring; resolves storage per environment (D1 binding → `bot/storage-d1`; `BOT_PERSIST` path → sqlite, `redis://` → redis, lazily-imported optional peers; else announced-ephemeral memory) — or pass `storage: (env) => Storage` when the choice is env-dependent (e.g. a D1 binding not named `DB`). Boot NARRATES the composition (`session: memory …`, `features: language(en,es) · menu(/settings)`) so every implicit decision is visible where you're looking. Runnable demo: `pnpm demo:bot`. |
@@ -745,4 +797,4 @@ Bump version in `package.json`, push to `main`. CI handles everything:
745
797
 
746
798
  ## License
747
799
 
748
- MIT
800
+ MIT
@@ -24,7 +24,6 @@ export * from "./kit.js";
24
24
  export * from "./language.js";
25
25
  export * from "./llm.js";
26
26
  export * from "./menu.js";
27
- export * from "./metrics.js";
28
27
  export * from "./notify.js";
29
28
  export * from "./payments/index.js";
30
29
  export * from "./storage.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/bot/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,cAAc,qBAAqB,CAAC;AACpC,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,sBAAsB,CAAC;AACrC,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,qBAAqB,CAAC;AACpC,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/bot/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,cAAc,qBAAqB,CAAC;AACpC,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,sBAAsB,CAAC;AACrC,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC;AAC5B,cAAc,qBAAqB,CAAC;AACpC,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC"}
package/dist/bot/index.js CHANGED
@@ -24,7 +24,6 @@ export * from "./kit.js";
24
24
  export * from "./language.js";
25
25
  export * from "./llm.js";
26
26
  export * from "./menu.js";
27
- export * from "./metrics.js";
28
27
  export * from "./notify.js";
29
28
  export * from "./payments/index.js";
30
29
  export * from "./storage.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/bot/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,cAAc,qBAAqB,CAAC;AACpC,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,sBAAsB,CAAC;AACrC,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,qBAAqB,CAAC;AACpC,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/bot/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,cAAc,qBAAqB,CAAC;AACpC,cAAc,YAAY,CAAC;AAC3B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,aAAa,CAAC;AAC5B,cAAc,sBAAsB,CAAC;AACrC,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC;AAC9B,cAAc,UAAU,CAAC;AACzB,cAAc,WAAW,CAAC;AAC1B,cAAc,aAAa,CAAC;AAC5B,cAAc,qBAAqB,CAAC;AACpC,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC"}
@@ -0,0 +1,6 @@
1
+ import { type DailyMetric } from "./report.js";
2
+ /** Render declarations stored beside observations; adding a metric requires no panel edit. */
3
+ export declare function renderMetrics(rows: readonly DailyMetric[], options?: {
4
+ daily?: boolean;
5
+ }): string;
6
+ //# sourceMappingURL=cli.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/metrics/cli.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,KAAK,WAAW,EAAoB,MAAM,aAAa,CAAC;AAEjE,8FAA8F;AAC9F,wBAAgB,aAAa,CAC5B,IAAI,EAAE,SAAS,WAAW,EAAE,EAC5B,OAAO,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAA;CAAO,GAC/B,MAAM,CAoDR"}
@@ -0,0 +1,50 @@
1
+ import { table, ui } from "../cli/index.js";
2
+ import { summarizeMetrics } from "./report.js";
3
+ /** Render declarations stored beside observations; adding a metric requires no panel edit. */
4
+ export function renderMetrics(rows, options = {}) {
5
+ if (!rows.length)
6
+ return "No recorded measurements in this window.";
7
+ const lines = [];
8
+ const totals = summarizeMetrics(rows);
9
+ const groups = new Map();
10
+ for (const total of totals) {
11
+ const key = JSON.stringify([total.project, total.key]);
12
+ const group = groups.get(key) ?? [];
13
+ group.push(total);
14
+ groups.set(key, group);
15
+ }
16
+ for (const group of groups.values()) {
17
+ const timing = group[0].kind === "timing";
18
+ const number = (n) => new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(n);
19
+ lines.push(ui.head(`${group[0].project} · ${group[0].label}`));
20
+ const source = options.daily
21
+ ? rows
22
+ .filter((r) => r.project === group[0].project && r.key === group[0].key)
23
+ .map((r) => ({ ...r, average: r.count ? r.sum / r.count : null }))
24
+ : group;
25
+ lines.push(table(source.map((r) => [
26
+ ...(options.daily ? ["day" in r ? String(r.day) : ""] : []),
27
+ Object.entries(r.dimensions)
28
+ .map(([k, v]) => `${k}=${v}`)
29
+ .join(" · ") || "all",
30
+ number(r.count),
31
+ ...(timing ? [`${number(r.average ?? 0)} ${r.unit}`.trim()] : []),
32
+ ]), {
33
+ head: [
34
+ ...(options.daily ? ["day"] : []),
35
+ "breakdown",
36
+ timing ? "samples" : "count",
37
+ ...(timing ? ["average"] : []),
38
+ ],
39
+ align: [
40
+ ...(options.daily ? ["l"] : []),
41
+ "l",
42
+ "r",
43
+ ...(timing ? ["r"] : []),
44
+ ],
45
+ }));
46
+ lines.push("");
47
+ }
48
+ return lines.join("\n").trimEnd();
49
+ }
50
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../../src/metrics/cli.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,54 @@
1
+ /** Product measurement with declared keys and bounded dimensions. No runtime or store dependency. */
2
+ export type MetricSpec = {
3
+ kind: "counter" | "timing";
4
+ label: string;
5
+ help?: string;
6
+ unit?: string;
7
+ perUser?: boolean;
8
+ dimensions?: Record<string, readonly string[]>;
9
+ };
10
+ export interface Measurement {
11
+ key: string;
12
+ day: string;
13
+ count: number;
14
+ sum: number;
15
+ user?: string | number;
16
+ dimensions: Record<string, string>;
17
+ spec: MetricSpec;
18
+ }
19
+ export interface MetricsOptions<Keys extends string = string> {
20
+ write: (measurement: Measurement) => Promise<unknown>;
21
+ /** Unordered user overlap, not an ordered conversion funnel. */
22
+ overlaps?: ReadonlyArray<readonly [Keys, Keys]>;
23
+ /** Failures are visible by default, but never reject a measurement call. */
24
+ onError?: (error: unknown, key: string) => void;
25
+ now?: () => Date;
26
+ }
27
+ export interface SampleOptions {
28
+ user?: string | number;
29
+ dimensions?: Record<string, string>;
30
+ }
31
+ export interface CounterHandle {
32
+ bump(options?: SampleOptions & {
33
+ n?: number;
34
+ }): Promise<void>;
35
+ }
36
+ export interface TimingHandle {
37
+ record(value: number, options?: SampleOptions): Promise<void>;
38
+ }
39
+ export interface MetricsSchema {
40
+ metrics: Array<{
41
+ key: string;
42
+ } & MetricSpec>;
43
+ overlaps: Array<{
44
+ from: string;
45
+ to: string;
46
+ }>;
47
+ }
48
+ export type Metrics<Spec extends Record<string, MetricSpec>> = {
49
+ [K in keyof Spec]: Spec[K]["kind"] extends "counter" ? CounterHandle : TimingHandle;
50
+ } & {
51
+ describe(): MetricsSchema;
52
+ };
53
+ export declare function defineMetrics<const Spec extends Record<string, MetricSpec>>(input: Spec, opts: MetricsOptions<Extract<keyof Spec, string>>): Metrics<Spec>;
54
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/metrics/index.ts"],"names":[],"mappings":"AAAA,qGAAqG;AACrG,MAAM,MAAM,UAAU,GAAG;IACxB,IAAI,EAAE,SAAS,GAAG,QAAQ,CAAC;IAC3B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC;CAC/C,CAAC;AAEF,MAAM,WAAW,WAAW;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,IAAI,EAAE,UAAU,CAAC;CACjB;AAED,MAAM,WAAW,cAAc,CAAC,IAAI,SAAS,MAAM,GAAG,MAAM;IAC3D,KAAK,EAAE,CAAC,WAAW,EAAE,WAAW,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;IACtD,gEAAgE;IAChE,QAAQ,CAAC,EAAE,aAAa,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC;IAChD,4EAA4E;IAC5E,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAChD,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC7B,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACpC;AACD,MAAM,WAAW,aAAa;IAC7B,IAAI,CAAC,OAAO,CAAC,EAAE,aAAa,GAAG;QAAE,CAAC,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9D;AACD,MAAM,WAAW,YAAY;IAC5B,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9D;AACD,MAAM,WAAW,aAAa;IAC7B,OAAO,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,UAAU,CAAC,CAAC;IAC7C,QAAQ,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC9C;AACD,MAAM,MAAM,OAAO,CAAC,IAAI,SAAS,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,IAAI;KAC7D,CAAC,IAAI,MAAM,IAAI,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,SAAS,GACjD,aAAa,GACb,YAAY;CACf,GAAG;IAAE,QAAQ,IAAI,aAAa,CAAA;CAAE,CAAC;AAIlC,wBAAgB,aAAa,CAAC,KAAK,CAAC,IAAI,SAAS,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,EAC1E,KAAK,EAAE,IAAI,EACX,IAAI,EAAE,cAAc,CAAC,OAAO,CAAC,MAAM,IAAI,EAAE,MAAM,CAAC,CAAC,GAC/C,OAAO,CAAC,IAAI,CAAC,CAqGf"}
@@ -0,0 +1,86 @@
1
+ const identifier = /^[a-zA-Z][a-zA-Z0-9_]*$/;
2
+ export function defineMetrics(input, opts) {
3
+ // Snapshot declarations: later caller mutations cannot change validation or labels.
4
+ const spec = structuredClone(input);
5
+ for (const [key, s] of Object.entries(spec)) {
6
+ if (!identifier.test(key) ||
7
+ key === "describe" ||
8
+ key === "constructor" ||
9
+ key === "prototype")
10
+ throw new Error(`metrics: invalid or reserved key "${key}"`);
11
+ if (!s.label.trim() || !["counter", "timing"].includes(s.kind))
12
+ throw new Error(`metrics: invalid declaration "${key}"`);
13
+ for (const [name, values] of Object.entries(s.dimensions ?? {})) {
14
+ if (!identifier.test(name) ||
15
+ !values.length ||
16
+ new Set(values).size !== values.length ||
17
+ values.some((v) => typeof v !== "string" || !v.length || v.length > 128))
18
+ throw new Error(`metrics: invalid dimension "${key}.${name}"`);
19
+ }
20
+ }
21
+ const overlaps = (opts.overlaps ?? []).map(([from, to]) => {
22
+ if (!spec[from]?.perUser || !spec[to]?.perUser)
23
+ throw new Error("metrics: overlap requires two declared perUser metrics");
24
+ return { from, to };
25
+ });
26
+ const report = opts.onError ??
27
+ ((_error, key) => console.warn(`metrics: measurement failed (${key})`));
28
+ async function write(key, count, sum, options) {
29
+ try {
30
+ if (!Number.isSafeInteger(count) ||
31
+ count <= 0 ||
32
+ !Number.isFinite(sum) ||
33
+ sum < 0)
34
+ throw new Error("metrics: samples must be finite and nonnegative; counts must be positive safe integers");
35
+ const s = spec[key];
36
+ const supplied = options?.dimensions ?? {};
37
+ const dimensions = {};
38
+ if (Object.keys(supplied).length !== Object.keys(s.dimensions ?? {}).length)
39
+ throw new Error("metrics: unexpected or missing dimensions");
40
+ for (const name of Object.keys(s.dimensions ?? {}).sort()) {
41
+ const value = supplied[name];
42
+ if (!s.dimensions?.[name].includes(value))
43
+ throw new Error(`metrics: invalid dimension ${name}`);
44
+ dimensions[name] = value;
45
+ }
46
+ const user = s.perUser ? options?.user : undefined;
47
+ if (user !== undefined &&
48
+ (typeof user === "number"
49
+ ? !Number.isSafeInteger(user)
50
+ : typeof user !== "string" || !user.length || user.length > 128))
51
+ throw new Error("metrics: invalid user identifier");
52
+ await opts.write({
53
+ key,
54
+ day: (opts.now?.() ?? new Date()).toISOString().slice(0, 10),
55
+ count,
56
+ sum,
57
+ user,
58
+ dimensions,
59
+ spec: structuredClone(s),
60
+ });
61
+ }
62
+ catch (error) {
63
+ try {
64
+ report(error, key);
65
+ }
66
+ catch {
67
+ /* Instrumentation must not break its caller. */
68
+ }
69
+ }
70
+ }
71
+ const out = {};
72
+ for (const [key, s] of Object.entries(spec)) {
73
+ out[key] =
74
+ s.kind === "counter"
75
+ ? { bump: (o) => write(key, o?.n ?? 1, 0, o) }
76
+ : { record: (value, o) => write(key, 1, value, o) };
77
+ }
78
+ return {
79
+ ...out,
80
+ describe: () => structuredClone({
81
+ metrics: Object.entries(spec).map(([key, s]) => ({ key, ...s })),
82
+ overlaps,
83
+ }),
84
+ };
85
+ }
86
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/metrics/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,27 @@
1
+ /** Store-independent daily observations, shared by adapters and renderers. */
2
+ export interface DailyMetric {
3
+ project: string;
4
+ key: string;
5
+ label: string;
6
+ kind: string;
7
+ help: string;
8
+ unit: string;
9
+ dimensions: Record<string, string>;
10
+ day: string;
11
+ count: number;
12
+ sum: number;
13
+ }
14
+ export interface MetricTotal {
15
+ project: string;
16
+ key: string;
17
+ label: string;
18
+ kind: string;
19
+ unit: string;
20
+ dimensions: Record<string, string>;
21
+ count: number;
22
+ sum: number;
23
+ average: number | null;
24
+ }
25
+ /** One report shape for terminal and web. No product names or metric lists. */
26
+ export declare function summarizeMetrics(rows: readonly DailyMetric[]): MetricTotal[];
27
+ //# sourceMappingURL=report.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report.d.ts","sourceRoot":"","sources":["../../src/metrics/report.ts"],"names":[],"mappings":"AAAA,8EAA8E;AAC9E,MAAM,WAAW,WAAW;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,WAAW;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACnC,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED,+EAA+E;AAC/E,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,SAAS,WAAW,EAAE,GAAG,WAAW,EAAE,CAiC5E"}
@@ -0,0 +1,31 @@
1
+ /** One report shape for terminal and web. No product names or metric lists. */
2
+ export function summarizeMetrics(rows) {
3
+ const totals = new Map();
4
+ for (const row of rows) {
5
+ const dimensions = Object.fromEntries(Object.entries(row.dimensions).sort(([a], [b]) => a.localeCompare(b)));
6
+ const key = JSON.stringify([row.project, row.key, dimensions]);
7
+ const total = totals.get(key) ?? {
8
+ project: row.project,
9
+ key: row.key,
10
+ label: row.label,
11
+ kind: row.kind,
12
+ unit: row.unit,
13
+ dimensions,
14
+ count: 0,
15
+ sum: 0,
16
+ average: null,
17
+ };
18
+ total.count += row.count;
19
+ total.sum += row.sum;
20
+ total.average =
21
+ total.kind === "timing" && total.count > 0
22
+ ? total.sum / total.count
23
+ : null;
24
+ totals.set(key, total);
25
+ }
26
+ return [...totals.values()].sort((a, b) => a.project.localeCompare(b.project) ||
27
+ a.key.localeCompare(b.key) ||
28
+ b.count - a.count ||
29
+ JSON.stringify(a.dimensions).localeCompare(JSON.stringify(b.dimensions)));
30
+ }
31
+ //# sourceMappingURL=report.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report.js","sourceRoot":"","sources":["../../src/metrics/report.ts"],"names":[],"mappings":"AA0BA,+EAA+E;AAC/E,MAAM,UAAU,gBAAgB,CAAC,IAA4B;IAC5D,MAAM,MAAM,GAAG,IAAI,GAAG,EAAuB,CAAC;IAC9C,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;QACxB,MAAM,UAAU,GAAG,MAAM,CAAC,WAAW,CACpC,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CACrE,CAAC;QACF,MAAM,GAAG,GAAG,IAAI,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,CAAC,GAAG,EAAE,UAAU,CAAC,CAAC,CAAC;QAC/D,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI;YAChC,OAAO,EAAE,GAAG,CAAC,OAAO;YACpB,GAAG,EAAE,GAAG,CAAC,GAAG;YACZ,KAAK,EAAE,GAAG,CAAC,KAAK;YAChB,IAAI,EAAE,GAAG,CAAC,IAAI;YACd,IAAI,EAAE,GAAG,CAAC,IAAI;YACd,UAAU;YACV,KAAK,EAAE,CAAC;YACR,GAAG,EAAE,CAAC;YACN,OAAO,EAAE,IAAI;SACb,CAAC;QACF,KAAK,CAAC,KAAK,IAAI,GAAG,CAAC,KAAK,CAAC;QACzB,KAAK,CAAC,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC;QACrB,KAAK,CAAC,OAAO;YACZ,KAAK,CAAC,IAAI,KAAK,QAAQ,IAAI,KAAK,CAAC,KAAK,GAAG,CAAC;gBACzC,CAAC,CAAC,KAAK,CAAC,GAAG,GAAG,KAAK,CAAC,KAAK;gBACzB,CAAC,CAAC,IAAI,CAAC;QACT,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IACxB,CAAC;IACD,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,IAAI,CAC/B,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CACR,CAAC,CAAC,OAAO,CAAC,aAAa,CAAC,CAAC,CAAC,OAAO,CAAC;QAClC,CAAC,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,CAAC,GAAG,CAAC;QAC1B,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK;QACjB,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,aAAa,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CACzE,CAAC;AACH,CAAC"}
@@ -0,0 +1,25 @@
1
+ import type { Measurement } from "./index.js";
2
+ import type { DailyMetric } from "./report.js";
3
+ /** Structural libSQL interface; callers own connections and credentials. */
4
+ export interface MetricsDatabase {
5
+ execute(statement: {
6
+ sql: string;
7
+ args: Array<string | number>;
8
+ }): Promise<{
9
+ rows: Iterable<Record<string, unknown>>;
10
+ }>;
11
+ batch(statements: Array<{
12
+ sql: string;
13
+ args: Array<string | number>;
14
+ }>, mode: "write"): Promise<unknown>;
15
+ }
16
+ /** Run explicitly during provisioning, never on a user's request. */
17
+ export declare const METRICS_SCHEMA: readonly ["CREATE TABLE IF NOT EXISTS metric_definition (project TEXT NOT NULL, key TEXT NOT NULL, kind TEXT NOT NULL, label TEXT NOT NULL, help TEXT NOT NULL, unit TEXT NOT NULL, PRIMARY KEY(project, key))", "CREATE TABLE IF NOT EXISTS metric_daily (project TEXT NOT NULL, key TEXT NOT NULL, dimensions TEXT NOT NULL, day TEXT NOT NULL, count INTEGER NOT NULL CHECK(count >= 0), sum REAL NOT NULL CHECK(sum >= 0), PRIMARY KEY(project, key, dimensions, day))", "CREATE TABLE IF NOT EXISTS metric_user_daily (project TEXT NOT NULL, key TEXT NOT NULL, dimensions TEXT NOT NULL, day TEXT NOT NULL, user TEXT NOT NULL, count INTEGER NOT NULL CHECK(count >= 0), sum REAL NOT NULL CHECK(sum >= 0), PRIMARY KEY(project, key, dimensions, day, user))", "CREATE INDEX IF NOT EXISTS metric_user_identity ON metric_user_daily(project, user)"];
18
+ export declare function sqliteMetricsWriter(db: MetricsDatabase, project: string): (m: Measurement) => Promise<void>;
19
+ /** Inclusive UTC dates. Errors propagate: unavailable data must never read as zero. */
20
+ export declare function readMetrics(db: MetricsDatabase, range: {
21
+ from: string;
22
+ to: string;
23
+ project?: string;
24
+ }): Promise<DailyMetric[]>;
25
+ //# sourceMappingURL=sqlite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlite.d.ts","sourceRoot":"","sources":["../../src/metrics/sqlite.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAC9C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAE/C,4EAA4E;AAC5E,MAAM,WAAW,eAAe;IAC/B,OAAO,CAAC,SAAS,EAAE;QAClB,GAAG,EAAE,MAAM,CAAC;QACZ,IAAI,EAAE,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC;KAC7B,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;KAAE,CAAC,CAAC;IACzD,KAAK,CACJ,UAAU,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,CAAA;KAAE,CAAC,EAChE,IAAI,EAAE,OAAO,GACX,OAAO,CAAC,OAAO,CAAC,CAAC;CACpB;AAED,qEAAqE;AACrE,eAAO,MAAM,cAAc,g0BAKjB,CAAC;AAEX,wBAAgB,mBAAmB,CAAC,EAAE,EAAE,eAAe,EAAE,OAAO,EAAE,MAAM,IAGzD,GAAG,WAAW,mBAkC5B;AAED,uFAAuF;AACvF,wBAAsB,WAAW,CAChC,EAAE,EAAE,eAAe,EACnB,KAAK,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,GACnD,OAAO,CAAC,WAAW,EAAE,CAAC,CAyBxB"}
@@ -0,0 +1,72 @@
1
+ /** Run explicitly during provisioning, never on a user's request. */
2
+ export const METRICS_SCHEMA = [
3
+ `CREATE TABLE IF NOT EXISTS metric_definition (project TEXT NOT NULL, key TEXT NOT NULL, kind TEXT NOT NULL, label TEXT NOT NULL, help TEXT NOT NULL, unit TEXT NOT NULL, PRIMARY KEY(project, key))`,
4
+ `CREATE TABLE IF NOT EXISTS metric_daily (project TEXT NOT NULL, key TEXT NOT NULL, dimensions TEXT NOT NULL, day TEXT NOT NULL, count INTEGER NOT NULL CHECK(count >= 0), sum REAL NOT NULL CHECK(sum >= 0), PRIMARY KEY(project, key, dimensions, day))`,
5
+ `CREATE TABLE IF NOT EXISTS metric_user_daily (project TEXT NOT NULL, key TEXT NOT NULL, dimensions TEXT NOT NULL, day TEXT NOT NULL, user TEXT NOT NULL, count INTEGER NOT NULL CHECK(count >= 0), sum REAL NOT NULL CHECK(sum >= 0), PRIMARY KEY(project, key, dimensions, day, user))`,
6
+ `CREATE INDEX IF NOT EXISTS metric_user_identity ON metric_user_daily(project, user)`,
7
+ ];
8
+ export function sqliteMetricsWriter(db, project) {
9
+ if (!/^[a-z][a-z0-9-]*$/.test(project))
10
+ throw new Error("metrics: invalid project");
11
+ return async (m) => {
12
+ const dimensions = JSON.stringify(m.dimensions);
13
+ const statements = [
14
+ {
15
+ sql: `INSERT INTO metric_definition VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(project, key) DO UPDATE SET kind=excluded.kind, label=excluded.label, help=excluded.help, unit=excluded.unit`,
16
+ args: [
17
+ project,
18
+ m.key,
19
+ m.spec.kind,
20
+ m.spec.label,
21
+ m.spec.help ?? "",
22
+ m.spec.unit ?? "",
23
+ ],
24
+ },
25
+ {
26
+ sql: `INSERT INTO metric_daily VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(project, key, dimensions, day) DO UPDATE SET count=count+excluded.count, sum=sum+excluded.sum`,
27
+ args: [project, m.key, dimensions, m.day, m.count, m.sum],
28
+ },
29
+ ];
30
+ if (m.user !== undefined)
31
+ statements.push({
32
+ sql: `INSERT INTO metric_user_daily VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(project, key, dimensions, day, user) DO UPDATE SET count=count+excluded.count, sum=sum+excluded.sum`,
33
+ args: [
34
+ project,
35
+ m.key,
36
+ dimensions,
37
+ m.day,
38
+ String(m.user),
39
+ m.count,
40
+ m.sum,
41
+ ],
42
+ });
43
+ await db.batch(statements, "write");
44
+ };
45
+ }
46
+ /** Inclusive UTC dates. Errors propagate: unavailable data must never read as zero. */
47
+ export async function readMetrics(db, range) {
48
+ for (const day of [range.from, range.to]) {
49
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(day) ||
50
+ new Date(day).toISOString().slice(0, 10) !== day)
51
+ throw new Error("metrics: invalid UTC date");
52
+ }
53
+ if (range.from > range.to)
54
+ throw new Error("metrics: reversed date range");
55
+ const result = await db.execute({
56
+ sql: `SELECT d.*, s.label, s.kind, s.help, s.unit FROM metric_daily d JOIN metric_definition s USING(project, key) WHERE day >= ? AND day <= ? ${range.project ? "AND project = ?" : ""} ORDER BY project, key, dimensions, day`,
57
+ args: [range.from, range.to, ...(range.project ? [range.project] : [])],
58
+ });
59
+ return Array.from(result.rows, (r) => ({
60
+ project: String(r.project),
61
+ key: String(r.key),
62
+ label: String(r.label),
63
+ kind: String(r.kind),
64
+ help: String(r.help),
65
+ unit: String(r.unit),
66
+ dimensions: JSON.parse(String(r.dimensions)),
67
+ day: String(r.day),
68
+ count: Number(r.count),
69
+ sum: Number(r.sum),
70
+ }));
71
+ }
72
+ //# sourceMappingURL=sqlite.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlite.js","sourceRoot":"","sources":["../../src/metrics/sqlite.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adriangalilea/utils",
3
- "version": "2.38.1",
3
+ "version": "4.0.0",
4
4
  "description": "TypeScript utilities - logger, currency, formatter, GramIO bot plugins, and more",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -184,9 +184,21 @@
184
184
  "types": "./dist/bot/inline-feedback.d.ts",
185
185
  "default": "./dist/bot/inline-feedback.js"
186
186
  },
187
- "./bot/metrics": {
188
- "types": "./dist/bot/metrics.d.ts",
189
- "default": "./dist/bot/metrics.js"
187
+ "./metrics": {
188
+ "types": "./dist/metrics/index.d.ts",
189
+ "default": "./dist/metrics/index.js"
190
+ },
191
+ "./metrics/sqlite": {
192
+ "types": "./dist/metrics/sqlite.d.ts",
193
+ "default": "./dist/metrics/sqlite.js"
194
+ },
195
+ "./metrics/report": {
196
+ "types": "./dist/metrics/report.d.ts",
197
+ "default": "./dist/metrics/report.js"
198
+ },
199
+ "./metrics/cli": {
200
+ "types": "./dist/metrics/cli.d.ts",
201
+ "default": "./dist/metrics/cli.js"
190
202
  },
191
203
  "./bot/text": {
192
204
  "types": "./dist/bot/text.d.ts",
@@ -203,7 +215,7 @@
203
215
  "scripts": {
204
216
  "build": "tsc",
205
217
  "dev": "tsx src/index.ts",
206
- "test": "echo 'Tests not configured yet'",
218
+ "test": "tsx tests/metrics-test.ts",
207
219
  "test:log": "tsx tests/log-test.ts",
208
220
  "test:runtime": "tsx tests/runtime-test.ts",
209
221
  "test:text": "tsx tests/text-test.ts",
@@ -1,106 +0,0 @@
1
- /**
2
- * Metric registry — self-serve product measurement, the `bot/flags` pattern
3
- * applied to counting. Declare a metric ONCE in code and it exists
4
- * everywhere: `metrics.<key>.bump()` / `.record(value)` at the call site,
5
- * generic day-bucketed storage underneath (no migration per metric), and
6
- * `metrics.describe()` as the schema admin panels render generically — a new
7
- * metric reaches every panel with zero panel edits.
8
- *
9
- * Built to answer PRODUCT questions, not just count:
10
- * volume how many times → counter
11
- * averages how long / how big → timing (count + sum → avg)
12
- * uniques how many distinct users → perUser: true
13
- * repeat used it again same day? → per-user day rows with count > 1
14
- * retention came back another day? → same user on ≥ 2 distinct days
15
- * funnels of those who did A, who did B → declared pairs over the
16
- * shared user space; panels render conversion % generically
17
- * depth how far do they go → per-user daily counts distribute
18
- *
19
- * Storage is ONE injected write op; the atomic upserts live with the
20
- * consumer's database. Contract: always add (count, sum) to the day bucket
21
- * for `key`; when `user` is present, ALSO to the per-user day bucket, e.g.:
22
- *
23
- * INSERT INTO metric (key, day, count, sum) VALUES (?1, date('now'), ?2, ?3)
24
- * ON CONFLICT (key, day) DO UPDATE SET count = count + ?2, sum = sum + ?3;
25
- * -- and when user is present:
26
- * INSERT INTO metric_user (key, user, day, count, sum) VALUES (?1, ?4, date('now'), ?2, ?3)
27
- * ON CONFLICT (key, user, day) DO UPDATE SET count = count + ?2, sum = sum + ?3;
28
- *
29
- * Per-user rows are personal data: wipe them in your forget-user path, and
30
- * prune by age (growth is users × metrics × days). Writes are FIRE-AND-FORGET
31
- * and never throw — measurement must never break the product. The registry
32
- * IS the cardinality cap: keys are declared identifiers, never free-form
33
- * strings. Money stays in ledgers; this is operational/product counting.
34
- *
35
- * @example
36
- * const metrics = defineMetrics({
37
- * chapterOpens: { kind: "counter", label: "chapter opens", perUser: true },
38
- * chapterPages: { kind: "counter", label: "chapter pages", perUser: true, help: "Back/Next taps" },
39
- * chapterLatency: { kind: "timing", label: "chapter latency", unit: "ms" },
40
- * }, {
41
- * write: (key, addCount, addSum, user) => store.metricBump(key, addCount, addSum, user),
42
- * funnels: [["chapterOpens", "chapterPages"]],
43
- * })
44
- *
45
- * void metrics.chapterOpens.bump({ user: ctx.from.id })
46
- * void metrics.chapterLatency.record(Date.now() - startedAt)
47
- * metrics.describe() // → { metrics: [...], funnels: [...] } — panels iterate this
48
- */
49
- /** One metric declaration. `perUser: true` unlocks uniques/repeat/retention/funnels for it. */
50
- export type MetricSpec = {
51
- kind: "counter";
52
- label: string;
53
- help?: string;
54
- perUser?: boolean;
55
- } | {
56
- kind: "timing";
57
- label: string;
58
- help?: string;
59
- unit?: string;
60
- perUser?: boolean;
61
- };
62
- export interface MetricsOptions<Keys extends string = string> {
63
- /**
64
- * The atomic day-bucket upsert(s): add `addCount`/`addSum` to today's row
65
- * for `key`, and — when `user` is present — to the per-user day row too
66
- * (one batch). Failures are swallowed by the registry; make the op itself
67
- * atomic.
68
- */
69
- write: (key: string, addCount: number, addSum: number, user?: number) => Promise<unknown>;
70
- /**
71
- * Declared conversion pairs over perUser metrics ("of users who did FROM,
72
- * how many did TO"). Panels render these generically; they are queries
73
- * over the shared user space, not extra storage.
74
- */
75
- funnels?: ReadonlyArray<readonly [Keys, Keys]>;
76
- }
77
- export interface CounterHandle {
78
- /** Count `n` events (default 1); pass `user` on perUser metrics. Fire-and-forget, never throws. */
79
- bump(opts?: {
80
- user?: number;
81
- n?: number;
82
- }): Promise<void>;
83
- }
84
- export interface TimingHandle {
85
- /** Record one sample; pass `user` on perUser metrics. Fire-and-forget, never throws. */
86
- record(value: number, opts?: {
87
- user?: number;
88
- }): Promise<void>;
89
- }
90
- export interface MetricsSchema {
91
- metrics: Array<{
92
- key: string;
93
- } & MetricSpec>;
94
- funnels: Array<{
95
- from: string;
96
- to: string;
97
- }>;
98
- }
99
- export type Metrics<Spec extends Record<string, MetricSpec>> = {
100
- [K in keyof Spec]: Spec[K]["kind"] extends "counter" ? CounterHandle : TimingHandle;
101
- } & {
102
- /** The registry schema, for panels to render generically. */
103
- describe(): MetricsSchema;
104
- };
105
- export declare function defineMetrics<const Spec extends Record<string, MetricSpec>>(spec: Spec, opts: MetricsOptions<Extract<keyof Spec, string>>): Metrics<Spec>;
106
- //# sourceMappingURL=metrics.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"metrics.d.ts","sourceRoot":"","sources":["../../src/bot/metrics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AAEH,+FAA+F;AAC/F,MAAM,MAAM,UAAU,GACnB;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GACpE;IACA,IAAI,EAAE,QAAQ,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,OAAO,CAAC;CACjB,CAAC;AAEL,MAAM,WAAW,cAAc,CAAC,IAAI,SAAS,MAAM,GAAG,MAAM;IAC3D;;;;;OAKG;IACH,KAAK,EAAE,CACN,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,EACd,IAAI,CAAC,EAAE,MAAM,KACT,OAAO,CAAC,OAAO,CAAC,CAAC;IACtB;;;;OAIG;IACH,OAAO,CAAC,EAAE,aAAa,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC;CAC/C;AAED,MAAM,WAAW,aAAa;IAC7B,mGAAmG;IACnG,IAAI,CAAC,IAAI,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1D;AACD,MAAM,WAAW,YAAY;IAC5B,wFAAwF;IACxF,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC/D;AAED,MAAM,WAAW,aAAa;IAC7B,OAAO,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAA;KAAE,GAAG,UAAU,CAAC,CAAC;IAC7C,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,EAAE,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC7C;AAED,MAAM,MAAM,OAAO,CAAC,IAAI,SAAS,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,IAAI;KAC7D,CAAC,IAAI,MAAM,IAAI,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,SAAS,GACjD,aAAa,GACb,YAAY;CACf,GAAG;IACH,6DAA6D;IAC7D,QAAQ,IAAI,aAAa,CAAC;CAC1B,CAAC;AAEF,wBAAgB,aAAa,CAAC,KAAK,CAAC,IAAI,SAAS,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,EAC1E,IAAI,EAAE,IAAI,EACV,IAAI,EAAE,cAAc,CAAC,OAAO,CAAC,MAAM,IAAI,EAAE,MAAM,CAAC,CAAC,GAC/C,OAAO,CAAC,IAAI,CAAC,CA2Cf"}
@@ -1,91 +0,0 @@
1
- /**
2
- * Metric registry — self-serve product measurement, the `bot/flags` pattern
3
- * applied to counting. Declare a metric ONCE in code and it exists
4
- * everywhere: `metrics.<key>.bump()` / `.record(value)` at the call site,
5
- * generic day-bucketed storage underneath (no migration per metric), and
6
- * `metrics.describe()` as the schema admin panels render generically — a new
7
- * metric reaches every panel with zero panel edits.
8
- *
9
- * Built to answer PRODUCT questions, not just count:
10
- * volume how many times → counter
11
- * averages how long / how big → timing (count + sum → avg)
12
- * uniques how many distinct users → perUser: true
13
- * repeat used it again same day? → per-user day rows with count > 1
14
- * retention came back another day? → same user on ≥ 2 distinct days
15
- * funnels of those who did A, who did B → declared pairs over the
16
- * shared user space; panels render conversion % generically
17
- * depth how far do they go → per-user daily counts distribute
18
- *
19
- * Storage is ONE injected write op; the atomic upserts live with the
20
- * consumer's database. Contract: always add (count, sum) to the day bucket
21
- * for `key`; when `user` is present, ALSO to the per-user day bucket, e.g.:
22
- *
23
- * INSERT INTO metric (key, day, count, sum) VALUES (?1, date('now'), ?2, ?3)
24
- * ON CONFLICT (key, day) DO UPDATE SET count = count + ?2, sum = sum + ?3;
25
- * -- and when user is present:
26
- * INSERT INTO metric_user (key, user, day, count, sum) VALUES (?1, ?4, date('now'), ?2, ?3)
27
- * ON CONFLICT (key, user, day) DO UPDATE SET count = count + ?2, sum = sum + ?3;
28
- *
29
- * Per-user rows are personal data: wipe them in your forget-user path, and
30
- * prune by age (growth is users × metrics × days). Writes are FIRE-AND-FORGET
31
- * and never throw — measurement must never break the product. The registry
32
- * IS the cardinality cap: keys are declared identifiers, never free-form
33
- * strings. Money stays in ledgers; this is operational/product counting.
34
- *
35
- * @example
36
- * const metrics = defineMetrics({
37
- * chapterOpens: { kind: "counter", label: "chapter opens", perUser: true },
38
- * chapterPages: { kind: "counter", label: "chapter pages", perUser: true, help: "Back/Next taps" },
39
- * chapterLatency: { kind: "timing", label: "chapter latency", unit: "ms" },
40
- * }, {
41
- * write: (key, addCount, addSum, user) => store.metricBump(key, addCount, addSum, user),
42
- * funnels: [["chapterOpens", "chapterPages"]],
43
- * })
44
- *
45
- * void metrics.chapterOpens.bump({ user: ctx.from.id })
46
- * void metrics.chapterLatency.record(Date.now() - startedAt)
47
- * metrics.describe() // → { metrics: [...], funnels: [...] } — panels iterate this
48
- */
49
- export function defineMetrics(spec, opts) {
50
- for (const [key, s] of Object.entries(spec)) {
51
- if (!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(key))
52
- throw new Error(`metrics: invalid key "${key}"`);
53
- if (!s.label)
54
- throw new Error(`metrics: "${key}" needs a label`);
55
- }
56
- for (const [from, to] of opts.funnels ?? []) {
57
- for (const k of [from, to]) {
58
- if (!spec[k])
59
- throw new Error(`metrics: funnel references unknown metric "${k}"`);
60
- if (!spec[k].perUser)
61
- throw new Error(`metrics: funnel metric "${k}" must be perUser (funnels join on user)`);
62
- }
63
- }
64
- const out = {};
65
- for (const [key, s] of Object.entries(spec)) {
66
- out[key] =
67
- s.kind === "counter"
68
- ? {
69
- async bump(o) {
70
- await opts
71
- .write(key, o?.n ?? 1, 0, s.perUser ? o?.user : undefined)
72
- .catch(() => { });
73
- },
74
- }
75
- : {
76
- async record(value, o) {
77
- await opts
78
- .write(key, 1, value, s.perUser ? o?.user : undefined)
79
- .catch(() => { });
80
- },
81
- };
82
- }
83
- return {
84
- ...out,
85
- describe: () => ({
86
- metrics: Object.entries(spec).map(([key, s]) => ({ key, ...s })),
87
- funnels: (opts.funnels ?? []).map(([from, to]) => ({ from, to })),
88
- }),
89
- };
90
- }
91
- //# sourceMappingURL=metrics.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"metrics.js","sourceRoot":"","sources":["../../src/bot/metrics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AAyDH,MAAM,UAAU,aAAa,CAC5B,IAAU,EACV,IAAiD;IAEjD,KAAK,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QAC7C,IAAI,CAAC,yBAAyB,CAAC,IAAI,CAAC,GAAG,CAAC;YACvC,MAAM,IAAI,KAAK,CAAC,yBAAyB,GAAG,GAAG,CAAC,CAAC;QAClD,IAAI,CAAC,CAAC,CAAC,KAAK;YAAE,MAAM,IAAI,KAAK,CAAC,aAAa,GAAG,iBAAiB,CAAC,CAAC;IAClE,CAAC;IACD,KAAK,MAAM,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,IAAI,CAAC,OAAO,IAAI,EAAE,EAAE,CAAC;QAC7C,KAAK,MAAM,CAAC,IAAI,CAAC,IAAI,EAAE,EAAE,CAAC,EAAE,CAAC;YAC5B,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;gBACX,MAAM,IAAI,KAAK,CAAC,8CAA8C,CAAC,GAAG,CAAC,CAAC;YACrE,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,OAAO;gBACnB,MAAM,IAAI,KAAK,CACd,2BAA2B,CAAC,0CAA0C,CACtE,CAAC;QACJ,CAAC;IACF,CAAC;IAED,MAAM,GAAG,GAAiD,EAAE,CAAC;IAC7D,KAAK,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QAC7C,GAAG,CAAC,GAAG,CAAC;YACP,CAAC,CAAC,IAAI,KAAK,SAAS;gBACnB,CAAC,CAAC;oBACA,KAAK,CAAC,IAAI,CAAC,CAAiC;wBAC3C,MAAM,IAAI;6BACR,KAAK,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC;6BACzD,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;oBACnB,CAAC;iBACD;gBACF,CAAC,CAAC;oBACA,KAAK,CAAC,MAAM,CAAC,KAAa,EAAE,CAAqB;wBAChD,MAAM,IAAI;6BACR,KAAK,CAAC,GAAG,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC;6BACrD,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;oBACnB,CAAC;iBACD,CAAC;IACN,CAAC;IACD,OAAO;QACN,GAAG,GAAG;QACN,QAAQ,EAAE,GAAkB,EAAE,CAAC,CAAC;YAC/B,OAAO,EAAE,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC;YAChE,OAAO,EAAE,CAAC,IAAI,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;SACjE,CAAC;KACe,CAAC;AACpB,CAAC"}