@cross-deck/buckets 0.6.0 → 0.8.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 +57 -3
- package/dist/index.d.mts +60 -14
- package/dist/index.d.ts +60 -14
- package/dist/index.js +117 -4
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +113 -5
- package/dist/index.mjs.map +1 -1
- package/dist/{sink-DDQmzqm5.d.mts → sink-B-i4E1fY.d.mts} +9 -1
- package/dist/{sink-DDQmzqm5.d.ts → sink-B-i4E1fY.d.ts} +9 -1
- package/dist/web.d.mts +2 -2
- package/dist/web.d.ts +2 -2
- package/dist/web.js +1 -1
- package/dist/web.js.map +1 -1
- package/dist/web.mjs +1 -1
- package/dist/web.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -74,9 +74,24 @@ await bucket("pulse-map", async () => {
|
|
|
74
74
|
});
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
-
**3.
|
|
78
|
-
`
|
|
79
|
-
|
|
77
|
+
**3. Read it back — right where you code.** Buckets writes a live readout to
|
|
78
|
+
`.crossdeck/buckets.md` (biggest bucket first). Open it, or just ask your AI session
|
|
79
|
+
**"read me my buckets"** — it's a plain file on disk, no dashboard required:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
# Buckets — reads on this surface
|
|
83
|
+
**32K reads** · 2026-06-19 (UTC)
|
|
84
|
+
|
|
85
|
+
| bucket | named | reads |
|
|
86
|
+
| ----------------- | :---: | ----: |
|
|
87
|
+
| headline-counters | ✓ | 31K |
|
|
88
|
+
| subscriptions | — | 1.1K |
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**No Crossdeck key needed for any of that.** `init()` with no `apiKey` meters locally
|
|
92
|
+
and writes the readout — the free, no-account wedge. Add a key and it *also* reports
|
|
93
|
+
up so the same numbers surface on your dashboard, with the drill-down, before/after,
|
|
94
|
+
and read-spike alerts. (Set `mirror: false` to turn the local file off.)
|
|
80
95
|
|
|
81
96
|
---
|
|
82
97
|
|
|
@@ -296,6 +311,20 @@ about. Two grains:
|
|
|
296
311
|
- **Tag a bucket** (coarse) — a whole handler or job: `bucket("pulse-map", handler)`
|
|
297
312
|
- **Tag a single read** (fine) — one query: `bucket("owner-lookup", () => db.doc(id).get())`
|
|
298
313
|
|
|
314
|
+
**Nest to drill down.** A `bucket()` inside another **composes into a path** —
|
|
315
|
+
the dashboard reads that path as a tree, so you tag the biggest bucket, see what's
|
|
316
|
+
under it, tag *that*, and the next-biggest surfaces. A tagged bucket also keeps the
|
|
317
|
+
collection it read as the leaf, so you never lose where the units actually went:
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
await bucket("analytics", () =>
|
|
321
|
+
bucket("rollup", () => db.collection("events").where(/*…*/).get()));
|
|
322
|
+
// → "analytics > rollup > col:events"
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
That waterfall — tag, drill, tag again — is how you walk a bill down from "where's
|
|
326
|
+
it all going?" to the exact line, one ship at a time.
|
|
327
|
+
|
|
299
328
|
`unknown` is never a dead end. It's a to-do with a one-line fix — exactly like a
|
|
300
329
|
custom analytics event you haven't named yet. You tag until you've found your
|
|
301
330
|
source; Buckets just shows the names resolving in.
|
|
@@ -318,6 +347,31 @@ primitive that the whole ecosystem can build on — so we open-sourced it, and w
|
|
|
318
347
|
publish the rollup schema so you can consume it with any tool you like, including
|
|
319
348
|
your own. **The best place to read Buckets data should be earned, never locked.**
|
|
320
349
|
|
|
350
|
+
### Free with the collector — and what Crossdeck adds
|
|
351
|
+
|
|
352
|
+
The collector is yours, free, forever: it meters every read on the surface you put
|
|
353
|
+
it on, never costs you reads to *run*, and you can point it at your own sink and read
|
|
354
|
+
the raw numbers yourself. That's a real tool on its own.
|
|
355
|
+
|
|
356
|
+
Two honest limits come with going it alone — and they're exactly what Crossdeck is for:
|
|
357
|
+
|
|
358
|
+
- **You see the surface you installed on.** Drop it in your server and you see server
|
|
359
|
+
reads — often the *minority*. Most apps read from the browser too (a separate
|
|
360
|
+
install), and the bill is the sum of both. **Crossdeck stitches server + browser +
|
|
361
|
+
every surface into one number**, so you stop reasoning from a slice.
|
|
362
|
+
- **Reading the numbers back yourself costs a few reads** — querying your own stored
|
|
363
|
+
rollups is still a read. **Crossdeck maintains the summary and serves it to you free,
|
|
364
|
+
live, any time** — the cost tool never costs you to look.
|
|
365
|
+
|
|
366
|
+
And because it's already wired in, Crossdeck turns the raw meter into the thing you
|
|
367
|
+
*act* on: the **drill-down** (tag → see the next-biggest → tag again, down to the
|
|
368
|
+
line), the **before/after fix verdict**, a **7-day baseline**, and **Slack alerts that
|
|
369
|
+
page you before the invoice**. The numbers are identical on both sides — same counts,
|
|
370
|
+
same source — Crossdeck just makes them whole, free to read, and impossible to miss.
|
|
371
|
+
|
|
372
|
+
Getting there is one step you were taking anyway: **onboard, install the SDK once, and
|
|
373
|
+
read-cost comes with it** — no second setup.
|
|
374
|
+
|
|
321
375
|
### Cost should page you like an exception — not surprise you like an invoice
|
|
322
376
|
|
|
323
377
|
You already know what your code *should* do. You wrote the analytics pipeline; you
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { S as Sink } from './sink-
|
|
2
|
-
export {
|
|
1
|
+
import { S as Sink, B as BucketsReport } from './sink-B-i4E1fY.mjs';
|
|
2
|
+
export { N as NullSink, O as OpCounts, R as ReportSink, a as ReportSinkConfig, b as ResourceCounts } from './sink-B-i4E1fY.mjs';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* A resource unit — what an adapter counts. Firestore emits `read`/`write`/
|
|
@@ -102,15 +102,50 @@ declare function refineCostTag(patch: Partial<CostTag>): void;
|
|
|
102
102
|
declare function currentCostTag(): CostTag;
|
|
103
103
|
/**
|
|
104
104
|
* `bucket(name, fn)` — the headline verb, the `track()` of cost. Run `fn` with
|
|
105
|
-
* every operation inside it attributed to the bucket `name`; the attribution
|
|
106
|
-
*
|
|
105
|
+
* every operation inside it attributed to the bucket `name`; the attribution rides
|
|
106
|
+
* the async subtree automatically. NESTS: a `bucket()` inside another COMPOSES into
|
|
107
|
+
* a path (`"analytics" > "rollup"` → `"analytics>rollup"`), so the dashboard can
|
|
108
|
+
* drill from the coarse bucket down into its parts. The one verb most developers touch:
|
|
107
109
|
*
|
|
108
|
-
* await bucket("
|
|
109
|
-
*
|
|
110
|
-
* });
|
|
110
|
+
* await bucket("analytics", () =>
|
|
111
|
+
* bucket("rollup", () => db.collection("events").where(...).get())); // → "analytics>rollup>col:events"
|
|
111
112
|
*/
|
|
112
113
|
declare function bucket<T>(name: string, fn: () => T): T;
|
|
113
114
|
|
|
115
|
+
declare const DEFAULT_MIRROR_DIR = ".crossdeck";
|
|
116
|
+
/**
|
|
117
|
+
* Wraps an optional upstream sink. On each flush it writes the running day-total
|
|
118
|
+
* locally, THEN (if an upstream sink was given — i.e. a key) reports onward.
|
|
119
|
+
* With no upstream it is a pure local meter: the wedge, working with no account.
|
|
120
|
+
*/
|
|
121
|
+
declare class MirrorSink implements Sink {
|
|
122
|
+
private readonly upstream;
|
|
123
|
+
private readonly dir;
|
|
124
|
+
private acc;
|
|
125
|
+
private announced;
|
|
126
|
+
private seeded;
|
|
127
|
+
constructor(upstream: Sink | null, dir?: string);
|
|
128
|
+
private jsonPath;
|
|
129
|
+
/** Seed the running total once from an existing same-day file (survives restarts). */
|
|
130
|
+
private seed;
|
|
131
|
+
flush(report: BucketsReport): Promise<void>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* readout — renders the local file a developer (or their AI session) reads back with
|
|
136
|
+
* "read me my buckets". PURE string building: no I/O, no database reads. The node
|
|
137
|
+
* mirror (./mirror) writes this to `.crossdeck/buckets.md` on each flush, so the
|
|
138
|
+
* readout works offline, for free, with no account.
|
|
139
|
+
*/
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The one line that closes every readout. Plain and factual: what the OSS shows you
|
|
143
|
+
* here, and what signing up adds — for free. No invented numbers, no urgency, no pitch.
|
|
144
|
+
*/
|
|
145
|
+
declare const READOUT_FOOTER: string;
|
|
146
|
+
/** Render the day's coalesced report as a human/AI-readable markdown readout. */
|
|
147
|
+
declare function renderReadout(report: BucketsReport): string;
|
|
148
|
+
|
|
114
149
|
/**
|
|
115
150
|
* @cross-deck/buckets — know exactly what every database read costs you, and who
|
|
116
151
|
* caused it. A tiny, never-throws collector for Firestore.
|
|
@@ -122,8 +157,13 @@ declare function bucket<T>(name: string, fn: () => T): T;
|
|
|
122
157
|
*/
|
|
123
158
|
|
|
124
159
|
interface InitOptions {
|
|
125
|
-
/**
|
|
126
|
-
|
|
160
|
+
/**
|
|
161
|
+
* The project's `cd_sk_` SECRET key. Server-to-server only — never a browser key.
|
|
162
|
+
* OPTIONAL: with no key, Buckets still meters locally and writes the readout to
|
|
163
|
+
* disk (`.crossdeck/buckets.md`) — the free, no-account wedge. Add a key and it
|
|
164
|
+
* also reports up to Crossdeck so the numbers surface on your dashboard.
|
|
165
|
+
*/
|
|
166
|
+
apiKey?: string;
|
|
127
167
|
/**
|
|
128
168
|
* Pass the namespace from `firebase-admin/firestore` to auto-install the read
|
|
129
169
|
* trap (recommended — this is what makes every read count with no per-call work).
|
|
@@ -137,14 +177,20 @@ interface InitOptions {
|
|
|
137
177
|
flushIntervalMs?: number;
|
|
138
178
|
/** Bring your own sink (self-host the rollups). Defaults to reporting up to Crossdeck. */
|
|
139
179
|
sink?: Sink;
|
|
180
|
+
/**
|
|
181
|
+
* Where to write the local readout — the file "read me my buckets" reads back.
|
|
182
|
+
* Defaults to `.crossdeck`. Pass `false` to turn the local mirror off entirely.
|
|
183
|
+
*/
|
|
184
|
+
mirror?: string | false;
|
|
140
185
|
/** Notified when a flush fails, so a dropped window is never silent. Best-effort. */
|
|
141
186
|
onError?: MeterConfig["onError"];
|
|
142
187
|
}
|
|
143
188
|
/**
|
|
144
|
-
* Configure Buckets once, at process start.
|
|
145
|
-
*
|
|
146
|
-
* trap so every read counts
|
|
189
|
+
* Configure Buckets once, at process start. Always meters locally and writes the
|
|
190
|
+
* readout to disk; if you pass `apiKey` (or your own `sink`) it ALSO reports up to
|
|
191
|
+
* Crossdeck. Pass `firestore` to install the universal read trap so every read counts
|
|
192
|
+
* automatically.
|
|
147
193
|
*/
|
|
148
|
-
declare function init(options
|
|
194
|
+
declare function init(options?: InitOptions): void;
|
|
149
195
|
|
|
150
|
-
export { type CostHint, type CostTag, type FirestoreClasses, type InitOptions, type MeterConfig, type OpType, type ResourceUnit, Sink, bucket, currentCostTag, enterCostTag, flush, init, init as initBuckets, installFirestoreMeter, record, recordDeletes, recordReads, recordWrites, refineCostTag, runWithCostTag };
|
|
196
|
+
export { BucketsReport, type CostHint, type CostTag, DEFAULT_MIRROR_DIR, type FirestoreClasses, type InitOptions, type MeterConfig, MirrorSink, type OpType, READOUT_FOOTER, type ResourceUnit, Sink, bucket, currentCostTag, enterCostTag, flush, init, init as initBuckets, installFirestoreMeter, record, recordDeletes, recordReads, recordWrites, refineCostTag, renderReadout, runWithCostTag };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { S as Sink } from './sink-
|
|
2
|
-
export {
|
|
1
|
+
import { S as Sink, B as BucketsReport } from './sink-B-i4E1fY.js';
|
|
2
|
+
export { N as NullSink, O as OpCounts, R as ReportSink, a as ReportSinkConfig, b as ResourceCounts } from './sink-B-i4E1fY.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* A resource unit — what an adapter counts. Firestore emits `read`/`write`/
|
|
@@ -102,15 +102,50 @@ declare function refineCostTag(patch: Partial<CostTag>): void;
|
|
|
102
102
|
declare function currentCostTag(): CostTag;
|
|
103
103
|
/**
|
|
104
104
|
* `bucket(name, fn)` — the headline verb, the `track()` of cost. Run `fn` with
|
|
105
|
-
* every operation inside it attributed to the bucket `name`; the attribution
|
|
106
|
-
*
|
|
105
|
+
* every operation inside it attributed to the bucket `name`; the attribution rides
|
|
106
|
+
* the async subtree automatically. NESTS: a `bucket()` inside another COMPOSES into
|
|
107
|
+
* a path (`"analytics" > "rollup"` → `"analytics>rollup"`), so the dashboard can
|
|
108
|
+
* drill from the coarse bucket down into its parts. The one verb most developers touch:
|
|
107
109
|
*
|
|
108
|
-
* await bucket("
|
|
109
|
-
*
|
|
110
|
-
* });
|
|
110
|
+
* await bucket("analytics", () =>
|
|
111
|
+
* bucket("rollup", () => db.collection("events").where(...).get())); // → "analytics>rollup>col:events"
|
|
111
112
|
*/
|
|
112
113
|
declare function bucket<T>(name: string, fn: () => T): T;
|
|
113
114
|
|
|
115
|
+
declare const DEFAULT_MIRROR_DIR = ".crossdeck";
|
|
116
|
+
/**
|
|
117
|
+
* Wraps an optional upstream sink. On each flush it writes the running day-total
|
|
118
|
+
* locally, THEN (if an upstream sink was given — i.e. a key) reports onward.
|
|
119
|
+
* With no upstream it is a pure local meter: the wedge, working with no account.
|
|
120
|
+
*/
|
|
121
|
+
declare class MirrorSink implements Sink {
|
|
122
|
+
private readonly upstream;
|
|
123
|
+
private readonly dir;
|
|
124
|
+
private acc;
|
|
125
|
+
private announced;
|
|
126
|
+
private seeded;
|
|
127
|
+
constructor(upstream: Sink | null, dir?: string);
|
|
128
|
+
private jsonPath;
|
|
129
|
+
/** Seed the running total once from an existing same-day file (survives restarts). */
|
|
130
|
+
private seed;
|
|
131
|
+
flush(report: BucketsReport): Promise<void>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* readout — renders the local file a developer (or their AI session) reads back with
|
|
136
|
+
* "read me my buckets". PURE string building: no I/O, no database reads. The node
|
|
137
|
+
* mirror (./mirror) writes this to `.crossdeck/buckets.md` on each flush, so the
|
|
138
|
+
* readout works offline, for free, with no account.
|
|
139
|
+
*/
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The one line that closes every readout. Plain and factual: what the OSS shows you
|
|
143
|
+
* here, and what signing up adds — for free. No invented numbers, no urgency, no pitch.
|
|
144
|
+
*/
|
|
145
|
+
declare const READOUT_FOOTER: string;
|
|
146
|
+
/** Render the day's coalesced report as a human/AI-readable markdown readout. */
|
|
147
|
+
declare function renderReadout(report: BucketsReport): string;
|
|
148
|
+
|
|
114
149
|
/**
|
|
115
150
|
* @cross-deck/buckets — know exactly what every database read costs you, and who
|
|
116
151
|
* caused it. A tiny, never-throws collector for Firestore.
|
|
@@ -122,8 +157,13 @@ declare function bucket<T>(name: string, fn: () => T): T;
|
|
|
122
157
|
*/
|
|
123
158
|
|
|
124
159
|
interface InitOptions {
|
|
125
|
-
/**
|
|
126
|
-
|
|
160
|
+
/**
|
|
161
|
+
* The project's `cd_sk_` SECRET key. Server-to-server only — never a browser key.
|
|
162
|
+
* OPTIONAL: with no key, Buckets still meters locally and writes the readout to
|
|
163
|
+
* disk (`.crossdeck/buckets.md`) — the free, no-account wedge. Add a key and it
|
|
164
|
+
* also reports up to Crossdeck so the numbers surface on your dashboard.
|
|
165
|
+
*/
|
|
166
|
+
apiKey?: string;
|
|
127
167
|
/**
|
|
128
168
|
* Pass the namespace from `firebase-admin/firestore` to auto-install the read
|
|
129
169
|
* trap (recommended — this is what makes every read count with no per-call work).
|
|
@@ -137,14 +177,20 @@ interface InitOptions {
|
|
|
137
177
|
flushIntervalMs?: number;
|
|
138
178
|
/** Bring your own sink (self-host the rollups). Defaults to reporting up to Crossdeck. */
|
|
139
179
|
sink?: Sink;
|
|
180
|
+
/**
|
|
181
|
+
* Where to write the local readout — the file "read me my buckets" reads back.
|
|
182
|
+
* Defaults to `.crossdeck`. Pass `false` to turn the local mirror off entirely.
|
|
183
|
+
*/
|
|
184
|
+
mirror?: string | false;
|
|
140
185
|
/** Notified when a flush fails, so a dropped window is never silent. Best-effort. */
|
|
141
186
|
onError?: MeterConfig["onError"];
|
|
142
187
|
}
|
|
143
188
|
/**
|
|
144
|
-
* Configure Buckets once, at process start.
|
|
145
|
-
*
|
|
146
|
-
* trap so every read counts
|
|
189
|
+
* Configure Buckets once, at process start. Always meters locally and writes the
|
|
190
|
+
* readout to disk; if you pass `apiKey` (or your own `sink`) it ALSO reports up to
|
|
191
|
+
* Crossdeck. Pass `firestore` to install the universal read trap so every read counts
|
|
192
|
+
* automatically.
|
|
147
193
|
*/
|
|
148
|
-
declare function init(options
|
|
194
|
+
declare function init(options?: InitOptions): void;
|
|
149
195
|
|
|
150
|
-
export { type CostHint, type CostTag, type FirestoreClasses, type InitOptions, type MeterConfig, type OpType, type ResourceUnit, Sink, bucket, currentCostTag, enterCostTag, flush, init, init as initBuckets, installFirestoreMeter, record, recordDeletes, recordReads, recordWrites, refineCostTag, runWithCostTag };
|
|
196
|
+
export { BucketsReport, type CostHint, type CostTag, DEFAULT_MIRROR_DIR, type FirestoreClasses, type InitOptions, type MeterConfig, MirrorSink, type OpType, READOUT_FOOTER, type ResourceUnit, Sink, bucket, currentCostTag, enterCostTag, flush, init, init as initBuckets, installFirestoreMeter, record, recordDeletes, recordReads, recordWrites, refineCostTag, renderReadout, runWithCostTag };
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var async_hooks = require('async_hooks');
|
|
4
|
+
var fs = require('fs');
|
|
5
|
+
var path = require('path');
|
|
4
6
|
|
|
5
7
|
// src/cost-context.ts
|
|
6
8
|
var DEFAULT_TAG = {};
|
|
@@ -18,8 +20,11 @@ function refineCostTag(patch) {
|
|
|
18
20
|
function currentCostTag() {
|
|
19
21
|
return store.getStore() ?? DEFAULT_TAG;
|
|
20
22
|
}
|
|
23
|
+
var BUCKET_SEP = ">";
|
|
21
24
|
function bucket(name, fn) {
|
|
22
|
-
|
|
25
|
+
const parent = currentCostTag().label;
|
|
26
|
+
const path = parent ? `${parent}${BUCKET_SEP}${name}` : name;
|
|
27
|
+
return runWithCostTag({ ...currentCostTag(), label: path }, fn);
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
// src/cost-meter.ts
|
|
@@ -58,7 +63,8 @@ function record(resource, quantity, hint) {
|
|
|
58
63
|
if (!Number.isFinite(quantity) || quantity <= 0) return;
|
|
59
64
|
const t = currentCostTag();
|
|
60
65
|
const date = utcDate();
|
|
61
|
-
const
|
|
66
|
+
const coll = hint?.collection ? `col:${hint.collection}` : null;
|
|
67
|
+
const label = t.label ? coll ? `${t.label}>${coll}` : t.label : coll ?? "uncategorized";
|
|
62
68
|
const lk = date + SEP + resource + SEP + label;
|
|
63
69
|
labelBuffer.set(lk, (labelBuffer.get(lk) ?? 0) + quantity);
|
|
64
70
|
const hk = date + SEP + resource + SEP + utcHour();
|
|
@@ -157,6 +163,107 @@ var ReportSink = class {
|
|
|
157
163
|
}
|
|
158
164
|
}
|
|
159
165
|
};
|
|
166
|
+
var NullSink = class {
|
|
167
|
+
async flush() {
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// src/readout.ts
|
|
172
|
+
var READOUT_FOOTER = "Buckets OSS shows the reads on this surface. Sign up to Crossdeck (free) to see every surface in one view, drill any bucket down to the exact query, track a fix before and after, and get paged when reads spike \u2014 cross-deck.com";
|
|
173
|
+
function fmt(n) {
|
|
174
|
+
if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
|
|
175
|
+
if (n >= 1e4) return Math.round(n / 1e3) + "K";
|
|
176
|
+
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
|
|
177
|
+
return String(Math.round(n));
|
|
178
|
+
}
|
|
179
|
+
function isUntagged(label) {
|
|
180
|
+
const root = label.split(">")[0];
|
|
181
|
+
return root.startsWith("col:") || root === "uncategorized" || root === "unknown";
|
|
182
|
+
}
|
|
183
|
+
function displayLabel(label) {
|
|
184
|
+
return label.split(">").map((s) => s.startsWith("col:") ? s.slice(4) : s).join(" \u203A ");
|
|
185
|
+
}
|
|
186
|
+
function renderReadout(report) {
|
|
187
|
+
const entries = Object.entries(report.byLabel ?? {}).map(([label, counts]) => ({ label, reads: counts.read ?? 0 })).filter((e) => e.reads > 0).sort((a, b) => b.reads - a.reads);
|
|
188
|
+
const total = entries.reduce((s, e) => s + e.reads, 0);
|
|
189
|
+
const out = [];
|
|
190
|
+
out.push(`# Buckets \u2014 reads on this surface`);
|
|
191
|
+
out.push(``);
|
|
192
|
+
out.push(`**${fmt(total)} reads** \xB7 ${report.date} (UTC)`);
|
|
193
|
+
out.push(``);
|
|
194
|
+
if (entries.length === 0) {
|
|
195
|
+
out.push(`No reads metered yet \u2014 install the collector and let your app serve some traffic.`);
|
|
196
|
+
} else {
|
|
197
|
+
out.push(`| bucket | named | reads |`);
|
|
198
|
+
out.push(`| --- | :---: | ---: |`);
|
|
199
|
+
for (const e of entries) {
|
|
200
|
+
out.push(`| ${displayLabel(e.label)} | ${isUntagged(e.label) ? "\u2014" : "\u2713"} | ${fmt(e.reads)} |`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
out.push(``);
|
|
204
|
+
out.push(`---`);
|
|
205
|
+
out.push(READOUT_FOOTER);
|
|
206
|
+
out.push(``);
|
|
207
|
+
return out.join("\n");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// src/mirror.ts
|
|
211
|
+
var DEFAULT_MIRROR_DIR = ".crossdeck";
|
|
212
|
+
function mergeInto(target, src) {
|
|
213
|
+
if (!src) return;
|
|
214
|
+
for (const [key, counts] of Object.entries(src)) {
|
|
215
|
+
const bag = target[key] ??= {};
|
|
216
|
+
for (const [res, n] of Object.entries(counts)) {
|
|
217
|
+
if (typeof n === "number") bag[res] = (bag[res] ?? 0) + n;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
var MirrorSink = class {
|
|
222
|
+
constructor(upstream, dir = DEFAULT_MIRROR_DIR) {
|
|
223
|
+
this.upstream = upstream;
|
|
224
|
+
this.dir = dir;
|
|
225
|
+
}
|
|
226
|
+
upstream;
|
|
227
|
+
dir;
|
|
228
|
+
acc = null;
|
|
229
|
+
announced = false;
|
|
230
|
+
seeded = false;
|
|
231
|
+
jsonPath() {
|
|
232
|
+
return path.join(this.dir, "buckets.json");
|
|
233
|
+
}
|
|
234
|
+
/** Seed the running total once from an existing same-day file (survives restarts). */
|
|
235
|
+
seed(date) {
|
|
236
|
+
if (this.seeded) return;
|
|
237
|
+
this.seeded = true;
|
|
238
|
+
try {
|
|
239
|
+
const prior = JSON.parse(fs.readFileSync(this.jsonPath(), "utf8"));
|
|
240
|
+
if (prior?.date === date && prior.byLabel) this.acc = prior;
|
|
241
|
+
} catch {
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
async flush(report) {
|
|
245
|
+
try {
|
|
246
|
+
this.seed(report.date);
|
|
247
|
+
if (!this.acc || this.acc.date !== report.date) {
|
|
248
|
+
this.acc = { date: report.date, byLabel: {}, byHour: {}, byMinute: {} };
|
|
249
|
+
}
|
|
250
|
+
mergeInto(this.acc.byLabel, report.byLabel);
|
|
251
|
+
mergeInto(this.acc.byHour ??= {}, report.byHour);
|
|
252
|
+
mergeInto(this.acc.byMinute ??= {}, report.byMinute);
|
|
253
|
+
fs.mkdirSync(this.dir, { recursive: true });
|
|
254
|
+
fs.writeFileSync(path.join(this.dir, "buckets.md"), renderReadout(this.acc));
|
|
255
|
+
fs.writeFileSync(this.jsonPath(), JSON.stringify(this.acc, null, 2));
|
|
256
|
+
if (!this.announced) {
|
|
257
|
+
this.announced = true;
|
|
258
|
+
console.log(
|
|
259
|
+
`Buckets: readout at ${path.join(this.dir, "buckets.md")} \u2014 open it, or ask your AI session to "read me my buckets".`
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
}
|
|
264
|
+
if (this.upstream) await this.upstream.flush(report);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
160
267
|
|
|
161
268
|
// src/adapters/firestore.ts
|
|
162
269
|
var installed = false;
|
|
@@ -278,12 +385,17 @@ function installFirestoreMeter(classes) {
|
|
|
278
385
|
}
|
|
279
386
|
|
|
280
387
|
// src/index.ts
|
|
281
|
-
function init(options) {
|
|
282
|
-
const
|
|
388
|
+
function init(options = {}) {
|
|
389
|
+
const upstream = options.sink ?? (options.apiKey ? new ReportSink({ apiKey: options.apiKey, endpoint: options.endpoint }) : null);
|
|
390
|
+
const sink2 = options.mirror === false ? upstream ?? new NullSink() : new MirrorSink(upstream, typeof options.mirror === "string" ? options.mirror : DEFAULT_MIRROR_DIR);
|
|
283
391
|
configureMeter({ sink: sink2, flushIntervalMs: options.flushIntervalMs, onError: options.onError });
|
|
284
392
|
if (options.firestore) installFirestoreMeter(options.firestore);
|
|
285
393
|
}
|
|
286
394
|
|
|
395
|
+
exports.DEFAULT_MIRROR_DIR = DEFAULT_MIRROR_DIR;
|
|
396
|
+
exports.MirrorSink = MirrorSink;
|
|
397
|
+
exports.NullSink = NullSink;
|
|
398
|
+
exports.READOUT_FOOTER = READOUT_FOOTER;
|
|
287
399
|
exports.ReportSink = ReportSink;
|
|
288
400
|
exports.bucket = bucket;
|
|
289
401
|
exports.currentCostTag = currentCostTag;
|
|
@@ -297,6 +409,7 @@ exports.recordDeletes = recordDeletes;
|
|
|
297
409
|
exports.recordReads = recordReads;
|
|
298
410
|
exports.recordWrites = recordWrites;
|
|
299
411
|
exports.refineCostTag = refineCostTag;
|
|
412
|
+
exports.renderReadout = renderReadout;
|
|
300
413
|
exports.runWithCostTag = runWithCostTag;
|
|
301
414
|
//# sourceMappingURL=index.js.map
|
|
302
415
|
//# sourceMappingURL=index.js.map
|