@cross-deck/buckets 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +449 -0
- package/dist/adapters/firestore.d.ts +41 -0
- package/dist/adapters/firestore.d.ts.map +1 -0
- package/dist/adapters/firestore.js +145 -0
- package/dist/adapters/firestore.js.map +1 -0
- package/dist/cost-context.d.ts +25 -0
- package/dist/cost-context.d.ts.map +1 -0
- package/dist/cost-context.js +46 -0
- package/dist/cost-context.js.map +1 -0
- package/dist/cost-meter.d.ts +28 -0
- package/dist/cost-meter.d.ts.map +1 -0
- package/dist/cost-meter.js +137 -0
- package/dist/cost-meter.js.map +1 -0
- package/dist/index.d.ts +44 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/sink.d.ts +56 -0
- package/dist/sink.d.ts.map +1 -0
- package/dist/sink.js +42 -0
- package/dist/sink.js.map +1 -0
- package/package.json +70 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Crossdeck
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# Buckets
|
|
4
|
+
|
|
5
|
+
### Know exactly what every database read costs you — and who caused it.
|
|
6
|
+
|
|
7
|
+
**Buckets is a zero-overhead cost attribution layer for your backend.**
|
|
8
|
+
Every read, write, and delete is tagged to the feature that served it and the
|
|
9
|
+
user who triggered it — automatically, with no blind spots, and without ever
|
|
10
|
+
becoming a cost itself.
|
|
11
|
+
|
|
12
|
+
[](LICENSE)
|
|
13
|
+
[](https://www.npmjs.com/package/@cross-deck/buckets)
|
|
14
|
+
[](#datastore-support)
|
|
15
|
+
[](https://cross-deck.com)
|
|
16
|
+
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## The 4am problem
|
|
22
|
+
|
|
23
|
+
You ship a feature. A week later your database bill is up 5×. Your provider's
|
|
24
|
+
console shows you *one number*: **9.6M reads today.** It does not tell you which
|
|
25
|
+
feature, which query, or which users are responsible. So you start guessing —
|
|
26
|
+
adding logs, bisecting deploys, staring at dashboards at 4am.
|
|
27
|
+
|
|
28
|
+
We lived this. On our own product, one tile was quietly issuing 15,000 reads
|
|
29
|
+
*per render*. It was hiding in plain sight for a day. When we finally instrumented
|
|
30
|
+
our read sites by hand, we *still* missed the path that mattered most — the
|
|
31
|
+
ingest pipeline, **1.4M reads a day, the majority of our entire bill, invisible.**
|
|
32
|
+
|
|
33
|
+
The lesson: **humans instrument what they're looking at, and miss the path that
|
|
34
|
+
matters.** Manual cost tracking doesn't fail loudly. It fails silently, and you
|
|
35
|
+
find out on the invoice.
|
|
36
|
+
|
|
37
|
+
Buckets fixes this by construction.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## Quickstart
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install @cross-deck/buckets
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**1. Install the meter once, at process start.** From this line on, *every*
|
|
48
|
+
operation on *every* code path is counted — no per-call-site work — and a coalesced
|
|
49
|
+
summary is reported to your Crossdeck project about once a minute. Your `cd_sk_`
|
|
50
|
+
secret key is all the wiring there is.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { init, installFirestoreMeter } from "@cross-deck/buckets";
|
|
54
|
+
import { getFirestore, Firestore, Query, DocumentReference } from "firebase-admin/firestore";
|
|
55
|
+
|
|
56
|
+
init({ apiKey: process.env.CROSSDECK_SECRET_KEY }); // reports up to Crossdeck (~1/min)
|
|
57
|
+
installFirestoreMeter({ Firestore, Query, DocumentReference }); // the trap
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
That's the whole setup. The collector now sits in your path to your database,
|
|
61
|
+
counts in memory, and reports a tiny summary up — **it never reads your data and
|
|
62
|
+
never adds a query.**
|
|
63
|
+
|
|
64
|
+
**2. Name the paths that matter** with `bucket()` — every operation inside is
|
|
65
|
+
attributed to that name (anything you don't name still shows up, labelled by its
|
|
66
|
+
collection).
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { bucket } from "@cross-deck/buckets";
|
|
70
|
+
|
|
71
|
+
await bucket("pulse-map", async () => {
|
|
72
|
+
const dots = await db.collection("visitors").where("live", "==", true).get(); // → pulse-map
|
|
73
|
+
const owner = await db.doc(`projects/${appId}`).get(); // → pulse-map
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**3. That's it.** Open your Crossdeck dashboard and your operations are there —
|
|
78
|
+
`pulse-map`, every other bucket you named, and an `unknown` row for anything you
|
|
79
|
+
haven't named yet. Buckets counted them at the database driver, not in your code.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## What you get
|
|
84
|
+
|
|
85
|
+
A small, cheap, daily document per app — the **rollup**. This is the entire output,
|
|
86
|
+
and it's a stable, public schema you can read with or without this library:
|
|
87
|
+
|
|
88
|
+
```jsonc
|
|
89
|
+
// costRollups/production_2026-06-18_proj_3a8f137
|
|
90
|
+
{
|
|
91
|
+
"env": "production",
|
|
92
|
+
"date": "2026-06-18",
|
|
93
|
+
"appId": "proj_3a8f137",
|
|
94
|
+
|
|
95
|
+
// who caused it → which feature → op type
|
|
96
|
+
"ops": {
|
|
97
|
+
"runtime": { "pulse-map": { "read": 412000, "write": 8 },
|
|
98
|
+
"dashboard": { "read": 765000 } },
|
|
99
|
+
"internal": { "reconcile": { "read": 1200, "write": 96 } }
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
// the fine grain: which surface / layer spent the reads
|
|
103
|
+
"byLabel": {
|
|
104
|
+
"people-feed": { "read": 412000 },
|
|
105
|
+
"people-journey": { "read": 3000 },
|
|
106
|
+
"col:events": { "read": 21000 }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Now "9.6M reads today" becomes *"765k of them are the dashboard, on the runtime
|
|
112
|
+
path — and within that, the people-feed layer is 137× heavier than the
|
|
113
|
+
journey-detail layer."* That's the difference between a number and an answer.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## "Did my fix work?" — the one-click loop
|
|
118
|
+
|
|
119
|
+
Buckets keeps reads at **hourly** grain for one reason: so you can ship a change
|
|
120
|
+
and *watch it land the same hour*, not guess from tomorrow's bill.
|
|
121
|
+
|
|
122
|
+
The loop:
|
|
123
|
+
|
|
124
|
+
1. You ship a fix to a heavy read path.
|
|
125
|
+
2. You click **I shipped a fix** on the dashboard. That stamps the exact moment.
|
|
126
|
+
3. Buckets splits the timeline there and shows the verdict in **reads / hour** —
|
|
127
|
+
the hours *before* your fix vs the hours *after* it:
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
since your fix · 2h ago
|
|
131
|
+
1,240 → 310 reads / hour −75% 930 fewer reads / hour · 2h observed
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
4. The first full hour after you click gives a real number; it settles as more
|
|
135
|
+
hours land. No marker set yet? The header shows the plain day-over-day rate and
|
|
136
|
+
a button to start the loop.
|
|
137
|
+
|
|
138
|
+
It is just a marker — a timestamp the dashboard remembers per project. Click it
|
|
139
|
+
again when you ship again (it moves to *now*); **clear** it to go back to the
|
|
140
|
+
day-over-day view. Nothing about the marker touches your read path or your bill;
|
|
141
|
+
it only changes where the dashboard draws the *before/after* line.
|
|
142
|
+
|
|
143
|
+
> Why a button and not a date field: a fix isn't a *day*, it's a *moment*. A date
|
|
144
|
+
> picker can't tell you whether the deploy you pushed twenty minutes ago worked —
|
|
145
|
+
> hourly before/after can.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## How it works
|
|
150
|
+
|
|
151
|
+
Three ideas, stacked. Understand these and you understand the whole library.
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
request boundary every read, anywhere
|
|
155
|
+
│ │
|
|
156
|
+
▼ ▼
|
|
157
|
+
┌──────────────┐ AsyncLocalStorage ┌──────────────────┐
|
|
158
|
+
│ ① THE TAG │ ─────────────────────▶ │ ② THE TRAP │
|
|
159
|
+
│ tag once, │ ambient context │ patch the SDK │
|
|
160
|
+
│ at the edge │ │ once, not your │
|
|
161
|
+
└──────────────┘ │ call sites │
|
|
162
|
+
└────────┬─────────┘
|
|
163
|
+
▼
|
|
164
|
+
┌──────────────────┐
|
|
165
|
+
│ ③ THE METER │
|
|
166
|
+
│ count in memory,│
|
|
167
|
+
│ flush ~1×/min │
|
|
168
|
+
└────────┬─────────┘
|
|
169
|
+
▼
|
|
170
|
+
the rollup doc
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
**① Tag once at the edge, attribute at the leaf.** Set the tag when a request
|
|
174
|
+
arrives; it propagates through every async fan-out automatically. Attribution
|
|
175
|
+
happens where the read *executes*, not where you guessed at the boundary.
|
|
176
|
+
|
|
177
|
+
**② Trap at the SDK, not at the call site.** Buckets patches the database driver's
|
|
178
|
+
read methods once. From then on every read is counted under the ambient tag — the
|
|
179
|
+
ingest job, the cron, the trigger, the path you forgot. **No blind spots, by
|
|
180
|
+
construction.** This is the part hand-rolled instrumentation can never get right.
|
|
181
|
+
|
|
182
|
+
**③ Count in memory, write rarely.** Counts accumulate in-process and flush to one
|
|
183
|
+
incremented document per (app, day) about once a minute — regardless of whether
|
|
184
|
+
you served 10 operations or 10 million. **The monitor never becomes the thing it
|
|
185
|
+
monitors.**
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
## Safe to put on your read path
|
|
190
|
+
|
|
191
|
+
Buckets patches your database driver. That demands a higher bar than most
|
|
192
|
+
libraries, so every wrapper is defensive by construction:
|
|
193
|
+
|
|
194
|
+
1. it calls the **real** method first and captures the result,
|
|
195
|
+
2. it counts inside a `try/catch` that **cannot throw into your code**,
|
|
196
|
+
3. it **always returns the real result, untouched.**
|
|
197
|
+
|
|
198
|
+
It physically cannot break a read, change a result, or add latency beyond a single
|
|
199
|
+
in-memory counter increment. If a count is ever wrong, it's a *measurement* error —
|
|
200
|
+
never a correctness or availability one. Install is idempotent. A failed flush
|
|
201
|
+
drops one window of counts rather than risk corrupting anything.
|
|
202
|
+
|
|
203
|
+
> Buckets is observability, not a transactional ledger. It is built to be wrong
|
|
204
|
+
> by a rounding error under catastrophe, and never, ever to take your app down.
|
|
205
|
+
|
|
206
|
+
| Guarantee | How |
|
|
207
|
+
|---|---|
|
|
208
|
+
| **Every read is caught** | SDK-level trap — no read on any path is ever uncounted or silent |
|
|
209
|
+
| **Every read is labeled** | Path-based cascade always tags the collection + project, even untagged |
|
|
210
|
+
| **Untagged is loud, never hidden** | Reads outside a tagged context surface as `unknown` coverage — surfaced, never dropped |
|
|
211
|
+
| **Never a cost driver** | In-memory buffers, ~1 write/min per app, tiny daily docs |
|
|
212
|
+
| **Never breaks a read** | Real-method-first · count-in-try/catch · always-return-untouched |
|
|
213
|
+
| **Concurrency-safe** | Atomic increments; snapshot-and-clear before each flush |
|
|
214
|
+
| **Honest under failure** | A dropped flush loses a window, never double-counts |
|
|
215
|
+
|
|
216
|
+
### What "no blind spots" actually means
|
|
217
|
+
|
|
218
|
+
Be precise, because the difference matters: **the trap guarantees every read is
|
|
219
|
+
*caught* and labeled by its collection — none is ever silent.** Sorting a read into
|
|
220
|
+
a *feature* and an *origin* ("this was the pulse-map, on a user's behalf") requires a
|
|
221
|
+
tag set at the request boundary. A read that runs outside any tagged context is
|
|
222
|
+
still caught and still labeled by collection (`col:events`) and project — it simply
|
|
223
|
+
lands in the **`unknown`** origin until you tag that entry point.
|
|
224
|
+
|
|
225
|
+
`unknown` is **first-class and loud**, never folded away and never filtered out of
|
|
226
|
+
the surface. A growing `unknown` bucket is the meter telling you "there's a real
|
|
227
|
+
read path here you haven't tagged yet" — which is exactly the signal you want, and
|
|
228
|
+
the opposite of a blind spot. Tag the entry point and it resolves into a named
|
|
229
|
+
feature. The one thing that never happens is a read going *uncounted*.
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## From `unknown` to named — tagging
|
|
234
|
+
|
|
235
|
+
The trap catches every read for free and labels it by collection (`col:events`) —
|
|
236
|
+
that answers *what's being read.* Buckets becomes genuinely useful the moment you
|
|
237
|
+
**name the read path yourself** — wrap it in a bucket:
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
import { bucket } from "@cross-deck/buckets";
|
|
241
|
+
|
|
242
|
+
// every read inside here is attributed to "nightly-export"
|
|
243
|
+
await bucket("nightly-export", async () => {
|
|
244
|
+
const rows = await db.collection("events").where("exported", "==", null).get();
|
|
245
|
+
// …
|
|
246
|
+
});
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Now those `col:events` reads that were sitting under `unknown` show up as
|
|
250
|
+
`nightly-export` on the dashboard. See an `unknown` bucket you can't explain?
|
|
251
|
+
**Drill in, wrap that path in a `bucket()`, ship, look again** — and keep going,
|
|
252
|
+
coarse to fine, until the read is named all the way down to the line you care
|
|
253
|
+
about. Two grains:
|
|
254
|
+
|
|
255
|
+
- **Tag a bucket** (coarse) — a whole handler or job: `bucket("pulse-map", handler)`
|
|
256
|
+
- **Tag a single read** (fine) — one query: `bucket("owner-lookup", () => db.doc(id).get())`
|
|
257
|
+
|
|
258
|
+
`unknown` is never a dead end. It's a to-do with a one-line fix — exactly like a
|
|
259
|
+
custom analytics event you haven't named yet. You tag until you've found your
|
|
260
|
+
source; Buckets just shows the names resolving in.
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## What Buckets is — and what it deliberately isn't
|
|
265
|
+
|
|
266
|
+
Buckets is **telemetry, done right.** It tells you *what* your costs are and
|
|
267
|
+
*exactly where they come from.* It does **not** tell you *why* a number changed or
|
|
268
|
+
*what to do about it* — and that restraint is intentional.
|
|
269
|
+
|
|
270
|
+
The labels Buckets emits are deliberately low-level: the collection, the feature,
|
|
271
|
+
the origin, the count. **It will never write an interpretation** — no
|
|
272
|
+
`scan-on-load`, no `regression`, no `anomaly`. Those are judgements, and judgement
|
|
273
|
+
is a separate concern that lives in a separate layer.
|
|
274
|
+
|
|
275
|
+
We think that's the honest line. Collection should be a free, open, commodity
|
|
276
|
+
primitive that the whole ecosystem can build on — so we open-sourced it, and we
|
|
277
|
+
publish the rollup schema so you can consume it with any tool you like, including
|
|
278
|
+
your own. **The best place to read Buckets data should be earned, never locked.**
|
|
279
|
+
|
|
280
|
+
### Cost should page you like an exception — not surprise you like an invoice
|
|
281
|
+
|
|
282
|
+
You already know what your code *should* do. You wrote the analytics pipeline; you
|
|
283
|
+
know it should read ~20k times a day, not 2M. The problem has never been a lack of
|
|
284
|
+
knowledge — it's that when reality departs from what you know, **nothing tells
|
|
285
|
+
you.** You find out at month-end, on a bill that's already due.
|
|
286
|
+
|
|
287
|
+
That's the difference between the two developers who hit the same bug. One reviews
|
|
288
|
+
the console at the end of the month and finds read volume that's been running ~100×
|
|
289
|
+
normal for weeks — a *verdict*, already billed, no cause attached. The other gets a
|
|
290
|
+
message ninety seconds in — *"analytics is at 2M reads, expected 20k"* — opens the
|
|
291
|
+
dashboard, sees which bucket, knows the code, and ships the fix before lunch. The
|
|
292
|
+
spike never gets a month to run. Same bug. The only difference is *when they found
|
|
293
|
+
out*.
|
|
294
|
+
|
|
295
|
+
A report is something you remember to open. An error is something that finds you.
|
|
296
|
+
Buckets gives you the open measurement that makes the difference possible — every
|
|
297
|
+
read attributed, in real time, with no blind spots.
|
|
298
|
+
|
|
299
|
+
### Get paged in Slack before the bill is in the post
|
|
300
|
+
|
|
301
|
+
This is the part that turns Buckets from a dashboard you remember to open into a
|
|
302
|
+
system that *finds you*. Connect Slack to [Crossdeck](https://cross-deck.com) and it
|
|
303
|
+
watches your buckets for you:
|
|
304
|
+
|
|
305
|
+
1. **It learns your normal.** For ~7 days it quietly builds a baseline of what each
|
|
306
|
+
hour looks like — *per hour-of-day*, so a busy 2pm is judged against other 2pms,
|
|
307
|
+
never against 3am. No alerts during this; it's collecting.
|
|
308
|
+
2. **It follows your fixes.** The baseline is recency-weighted: arrive bleeding 2M
|
|
309
|
+
reads, fix down to 50k, and it forgets the 2M and settles at your new normal. So
|
|
310
|
+
a later 50k→500k spike is a *real* deviation, not lost under an old number.
|
|
311
|
+
3. **It pings only on a real surge.** When a completed hour breaks the baseline (a
|
|
312
|
+
big statistical jump *and* a meaningful multiple — both gates, so a tiny bucket
|
|
313
|
+
jittering never pages you), you get a Slack message:
|
|
314
|
+
> 🟡 **Read spike detected** — *512,000 reads* in the last hour, about *10×* your
|
|
315
|
+
> normal for this time of day (~50,000). Open Buckets to see which bucket moved.
|
|
316
|
+
4. **You stay in control.** Shipped a feature you *know* adds reads? One click —
|
|
317
|
+
**"Expected — quiet for 24h"** — and it hushes while the baseline re-bases. Your
|
|
318
|
+
knowledge of your own roadmap is the final authority; it never pretends to know
|
|
319
|
+
better.
|
|
320
|
+
|
|
321
|
+
An ongoing spike pings **once**, not every hour. Cold-start means it never cries
|
|
322
|
+
wolf before it knows you. And — the rule that holds the whole system together — the
|
|
323
|
+
thing that watches your read bill **never runs one up**: every check reads a small
|
|
324
|
+
maintained summary, never scans your data.
|
|
325
|
+
|
|
326
|
+
> The open collector in this repo produces the buckets. The baseline, the anomaly
|
|
327
|
+
> detection, and the Slack alert are the layer [Crossdeck](https://cross-deck.com)
|
|
328
|
+
> builds on top. The collector stands alone, free, forever — Crossdeck is where the
|
|
329
|
+
> buckets start paging you.
|
|
330
|
+
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
## Counting model
|
|
334
|
+
|
|
335
|
+
| Operation | Counted as |
|
|
336
|
+
|---|---|
|
|
337
|
+
| Query returning N docs | N reads |
|
|
338
|
+
| Empty query | 1 read *(your provider bills a minimum of one)* |
|
|
339
|
+
| `doc.get()` | 1 read |
|
|
340
|
+
| `getAll(...refs)` | one read per ref |
|
|
341
|
+
| Write / delete | 1 each |
|
|
342
|
+
|
|
343
|
+
Counts are **defensible** — every one traces to a billed operation, so the rollup
|
|
344
|
+
reconciles against your provider's invoice instead of drifting from it.
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## Datastore support
|
|
349
|
+
|
|
350
|
+
| Datastore | Status |
|
|
351
|
+
|---|---|
|
|
352
|
+
| **Google Cloud Firestore** (firebase-admin) | ✅ Supported |
|
|
353
|
+
| Postgres · DynamoDB · MongoDB | 🔜 Adapter interface is public — contributions welcome |
|
|
354
|
+
|
|
355
|
+
The trap *pattern* generalises to any driver with interceptable read methods, and
|
|
356
|
+
the storage `Sink` interface is datastore-agnostic. Firestore is the only adapter
|
|
357
|
+
we ship and support today — we'd rather support one datastore excellently than
|
|
358
|
+
five badly.
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## API
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
init({ apiKey, endpoint?, flushIntervalMs? }) // configure once; reports up to Crossdeck
|
|
366
|
+
|
|
367
|
+
bucket(name, fn) // ← the one verb you'll use: attribute everything inside to `name`
|
|
368
|
+
|
|
369
|
+
installFirestoreMeter(classes) // the Firestore trap (the only adapter today)
|
|
370
|
+
flush() // force a flush (tests / shutdown)
|
|
371
|
+
|
|
372
|
+
// lower-level, if you need it:
|
|
373
|
+
runWithCostTag(tag, fn) · enterCostTag(tag) · refineCostTag(patch) · currentCostTag()
|
|
374
|
+
recordReads(n) · recordWrites(n) · recordDeletes(n)
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
One import to set up, one call to install, one verb to name a path. That's the
|
|
378
|
+
whole footprint. The reporting (collector → `POST /v1/buckets/report` → your
|
|
379
|
+
maintained rollup → dashboard) happens automatically — you never touch it.
|
|
380
|
+
|
|
381
|
+
---
|
|
382
|
+
|
|
383
|
+
## Roadmap
|
|
384
|
+
|
|
385
|
+
- [x] Core: tag · trap · meter · rollup
|
|
386
|
+
- [x] Firestore adapter
|
|
387
|
+
- [x] Public, versioned rollup schema
|
|
388
|
+
- [ ] Postgres adapter
|
|
389
|
+
- [ ] `compute` (invocation + CPU-ms) attribution in the public build
|
|
390
|
+
- [ ] OpenTelemetry export
|
|
391
|
+
- [ ] Community sink adapters (BigQuery, ClickHouse, S3)
|
|
392
|
+
|
|
393
|
+
The *intelligence* on top of these rollups — regression detection, deploy
|
|
394
|
+
attribution, forecasting, suggested fixes — is **not** on this roadmap by design.
|
|
395
|
+
That's the line between the open primitive and the product built on it.
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
## FAQ
|
|
400
|
+
|
|
401
|
+
**Is this really free, or is there a catch?**
|
|
402
|
+
Genuinely free, MIT, forever. The collector and the schema are the open primitive.
|
|
403
|
+
We make money on the *interpretation* layer (Crossdeck), not on locking up your
|
|
404
|
+
own cost data. Your rollups are yours, in your datastore, readable by anything.
|
|
405
|
+
|
|
406
|
+
**Won't patching the SDK slow down my reads?**
|
|
407
|
+
No. The overhead is one in-memory map increment per read. No I/O is added to the
|
|
408
|
+
read path; writes happen in a batched flush roughly once a minute.
|
|
409
|
+
|
|
410
|
+
**What if Buckets crashes or its sink is down?**
|
|
411
|
+
Your reads are unaffected — every wrapper returns the real result no matter what
|
|
412
|
+
happens inside the meter. You lose at most one ~60-second window of *counts*.
|
|
413
|
+
|
|
414
|
+
**Why not just read my cloud provider's billing export?**
|
|
415
|
+
Billing exports tell you the total. They can't tell you *which feature* or *which
|
|
416
|
+
user* — the attribution that lets you actually fix the cost. That's the entire
|
|
417
|
+
point of Buckets.
|
|
418
|
+
|
|
419
|
+
**Does it work with `firebase-functions` / serverless cold starts?**
|
|
420
|
+
Yes. The meter flushes on `SIGTERM` and `beforeExit`, so a scaling-to-zero
|
|
421
|
+
instance writes its final window before it dies.
|
|
422
|
+
|
|
423
|
+
---
|
|
424
|
+
|
|
425
|
+
## Contributing
|
|
426
|
+
|
|
427
|
+
Adapters, tests, and docs are the highest-leverage contributions. The bar for
|
|
428
|
+
anything touching the read path is the safety contract above — real-method-first,
|
|
429
|
+
never-throws, always-returns-untouched, with a test that proves it. See
|
|
430
|
+
[CONTRIBUTING.md](CONTRIBUTING.md).
|
|
431
|
+
|
|
432
|
+
## Who's behind this
|
|
433
|
+
|
|
434
|
+
Buckets is built and battle-tested by **[Crossdeck](https://cross-deck.com)** —
|
|
435
|
+
revenue, analytics, identity, and cost intelligence for app developers. We run
|
|
436
|
+
Buckets in production on every read our own platform serves. If it's good enough
|
|
437
|
+
to protect our invoice, it's good enough for yours.
|
|
438
|
+
|
|
439
|
+
## License
|
|
440
|
+
|
|
441
|
+
[MIT](LICENSE). Use it anywhere, including commercially. No attribution required
|
|
442
|
+
(though a ⭐ is always appreciated).
|
|
443
|
+
|
|
444
|
+
<div align="center">
|
|
445
|
+
<br>
|
|
446
|
+
<strong>Stop guessing what your database costs. Start knowing.</strong>
|
|
447
|
+
<br><br>
|
|
448
|
+
<code>npm install @cross-deck/buckets</code>
|
|
449
|
+
</div>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
type AnyFn = (...args: any[]) => any;
|
|
2
|
+
/**
|
|
3
|
+
* The firebase-admin Firestore classes to patch. Pass the module namespace from
|
|
4
|
+
* `firebase-admin/firestore` — only the prototypes present are patched.
|
|
5
|
+
*/
|
|
6
|
+
export interface FirestoreClasses {
|
|
7
|
+
Query?: {
|
|
8
|
+
prototype: {
|
|
9
|
+
get?: AnyFn;
|
|
10
|
+
};
|
|
11
|
+
};
|
|
12
|
+
DocumentReference?: {
|
|
13
|
+
prototype: {
|
|
14
|
+
get?: AnyFn;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
Transaction?: {
|
|
18
|
+
prototype: {
|
|
19
|
+
get?: AnyFn;
|
|
20
|
+
getAll?: AnyFn;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
Firestore?: {
|
|
24
|
+
prototype: {
|
|
25
|
+
getAll?: AnyFn;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Install the universal read meter on the firebase-admin Firestore classes. Call
|
|
31
|
+
* ONCE at process start, before any reads. Pass the namespace from
|
|
32
|
+
* `firebase-admin/firestore` so the exact prototypes the SDK uses are patched:
|
|
33
|
+
*
|
|
34
|
+
* import * as Firestore from "firebase-admin/firestore";
|
|
35
|
+
* installFirestoreMeter(Firestore);
|
|
36
|
+
*/
|
|
37
|
+
export declare function installFirestoreMeter(classes: FirestoreClasses): void;
|
|
38
|
+
/** Test-only: reset the install guard so a suite can re-patch fresh prototypes. */
|
|
39
|
+
export declare function __resetFirestoreMeterForTests(): void;
|
|
40
|
+
export {};
|
|
41
|
+
//# sourceMappingURL=firestore.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"firestore.d.ts","sourceRoot":"","sources":["../../src/adapters/firestore.ts"],"names":[],"mappings":"AA+BA,KAAK,KAAK,GAAG,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,GAAG,CAAC;AAErC;;;GAGG;AACH,MAAM,WAAW,gBAAgB;IAC/B,KAAK,CAAC,EAAE;QAAE,SAAS,EAAE;YAAE,GAAG,CAAC,EAAE,KAAK,CAAA;SAAE,CAAA;KAAE,CAAC;IACvC,iBAAiB,CAAC,EAAE;QAAE,SAAS,EAAE;YAAE,GAAG,CAAC,EAAE,KAAK,CAAA;SAAE,CAAA;KAAE,CAAC;IACnD,WAAW,CAAC,EAAE;QAAE,SAAS,EAAE;YAAE,GAAG,CAAC,EAAE,KAAK,CAAC;YAAC,MAAM,CAAC,EAAE,KAAK,CAAA;SAAE,CAAA;KAAE,CAAC;IAC7D,SAAS,CAAC,EAAE;QAAE,SAAS,EAAE;YAAE,MAAM,CAAC,EAAE,KAAK,CAAA;SAAE,CAAA;KAAE,CAAC;CAC/C;AAwDD;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,gBAAgB,GAAG,IAAI,CAsDrE;AAED,mFAAmF;AACnF,wBAAgB,6BAA6B,IAAI,IAAI,CAEpD"}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* adapters/firestore — the universal Firestore read meter (the trap).
|
|
3
|
+
*
|
|
4
|
+
* THE LESSON (learned on a real product): per-call-site `recordReads()`
|
|
5
|
+
* instrumentation MISSES paths. You meter the read sites you're looking at and
|
|
6
|
+
* leave the cron / trigger / ingest path uncounted — often the majority of reads,
|
|
7
|
+
* invisible. Humans tag what they see and miss the path that matters.
|
|
8
|
+
*
|
|
9
|
+
* THE FIX: patch the admin SDK's read methods ONCE. From install onward, EVERY
|
|
10
|
+
* read — anywhere, on any code path — is counted under the ambient tag, with zero
|
|
11
|
+
* per-call-site work and no blind spots.
|
|
12
|
+
*
|
|
13
|
+
* SAFETY CONTRACT — this sits on your production read path, so it is defensive by
|
|
14
|
+
* construction. Each wrapper:
|
|
15
|
+
* 1. calls the REAL method first and captures the result,
|
|
16
|
+
* 2. counts in a try/catch that can never throw into the caller,
|
|
17
|
+
* 3. ALWAYS returns the real result, untouched.
|
|
18
|
+
* It cannot break a read, change a result, or add latency beyond one in-memory
|
|
19
|
+
* counter increment. A wrong count is a measurement error, never a correctness or
|
|
20
|
+
* availability one. Idempotent — calling it twice patches once.
|
|
21
|
+
*
|
|
22
|
+
* COUNTING MODEL — a query returning N docs = N reads (an empty result still bills
|
|
23
|
+
* 1, which the meter enforces). A document get = 1. getAll(...) = the ref count.
|
|
24
|
+
* CollectionReference.get IS Query.get (shared prototype method), so patching Query
|
|
25
|
+
* covers collections with no double-count.
|
|
26
|
+
*/
|
|
27
|
+
import { recordReads } from "../cost-meter.js";
|
|
28
|
+
let installed = false;
|
|
29
|
+
/** `projects/{id}/…` → the project id, else undefined. Pure string op. */
|
|
30
|
+
function projectFromPath(path) {
|
|
31
|
+
const parts = path.split("/");
|
|
32
|
+
const i = parts.indexOf("projects");
|
|
33
|
+
return i >= 0 && parts[i + 1] ? parts[i + 1] : undefined;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Derive { collection, projectId } from the read target's path so an UNtagged read
|
|
37
|
+
* cascades to `col:<collection>` instead of "uncategorized". PURE CPU; never reads,
|
|
38
|
+
* never throws. Falls back to firebase-admin's internal `_queryOptions` for filtered
|
|
39
|
+
* queries (which don't expose `.path`).
|
|
40
|
+
*/
|
|
41
|
+
function hintFrom(target) {
|
|
42
|
+
try {
|
|
43
|
+
const p = typeof target?.path === "string" ? target.path : "";
|
|
44
|
+
if (p) {
|
|
45
|
+
const parts = p.split("/").filter(Boolean);
|
|
46
|
+
const collection = parts.length % 2 === 0 ? parts[parts.length - 2] : parts[parts.length - 1];
|
|
47
|
+
return { collection, projectId: projectFromPath(p) };
|
|
48
|
+
}
|
|
49
|
+
const qo = target?._queryOptions;
|
|
50
|
+
if (qo) {
|
|
51
|
+
const collection = typeof qo.collectionId === "string" ? qo.collectionId : undefined;
|
|
52
|
+
const parent = typeof qo.parentPath?.relativeName === "string"
|
|
53
|
+
? qo.parentPath.relativeName
|
|
54
|
+
: typeof qo.parentPath?.toString === "function"
|
|
55
|
+
? String(qo.parentPath.toString())
|
|
56
|
+
: "";
|
|
57
|
+
return { collection, projectId: parent ? projectFromPath(parent) : undefined };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* the meter must never throw */
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
function meterSnap(snap, hint) {
|
|
66
|
+
try {
|
|
67
|
+
const size = snap?.size;
|
|
68
|
+
recordReads(typeof size === "number" ? size : 1, hint);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
/* best-effort */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function meterCount(n, hint) {
|
|
75
|
+
try {
|
|
76
|
+
recordReads(n, hint);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
/* best-effort */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Install the universal read meter on the firebase-admin Firestore classes. Call
|
|
84
|
+
* ONCE at process start, before any reads. Pass the namespace from
|
|
85
|
+
* `firebase-admin/firestore` so the exact prototypes the SDK uses are patched:
|
|
86
|
+
*
|
|
87
|
+
* import * as Firestore from "firebase-admin/firestore";
|
|
88
|
+
* installFirestoreMeter(Firestore);
|
|
89
|
+
*/
|
|
90
|
+
export function installFirestoreMeter(classes) {
|
|
91
|
+
if (installed)
|
|
92
|
+
return;
|
|
93
|
+
installed = true;
|
|
94
|
+
const { Query, DocumentReference, Transaction, Firestore } = classes;
|
|
95
|
+
// Query.get — covers Query AND CollectionReference (shared prototype method).
|
|
96
|
+
const qGet = Query?.prototype?.get;
|
|
97
|
+
if (qGet) {
|
|
98
|
+
Query.prototype.get = async function (...args) {
|
|
99
|
+
const snap = await qGet.apply(this, args);
|
|
100
|
+
meterSnap(snap, hintFrom(this));
|
|
101
|
+
return snap;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// DocumentReference.get — a single doc = 1 read.
|
|
105
|
+
const dGet = DocumentReference?.prototype?.get;
|
|
106
|
+
if (dGet) {
|
|
107
|
+
DocumentReference.prototype.get = async function (...args) {
|
|
108
|
+
const snap = await dGet.apply(this, args);
|
|
109
|
+
meterCount(1, hintFrom(this));
|
|
110
|
+
return snap;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// Transaction.get — query or doc; size when present, else 1.
|
|
114
|
+
const tGet = Transaction?.prototype?.get;
|
|
115
|
+
if (tGet) {
|
|
116
|
+
Transaction.prototype.get = async function (...args) {
|
|
117
|
+
const res = await tGet.apply(this, args);
|
|
118
|
+
meterSnap(res, hintFrom(args[0]));
|
|
119
|
+
return res;
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
// Transaction.getAll(...refs) — one read per ref.
|
|
123
|
+
const tGetAll = Transaction?.prototype?.getAll;
|
|
124
|
+
if (tGetAll) {
|
|
125
|
+
Transaction.prototype.getAll = async function (...args) {
|
|
126
|
+
const res = await tGetAll.apply(this, args);
|
|
127
|
+
meterCount(Array.isArray(res) ? res.length : args.length || 1, hintFrom(args[0]));
|
|
128
|
+
return res;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// Firestore.getAll(...refs) — batched doc reads.
|
|
132
|
+
const fGetAll = Firestore?.prototype?.getAll;
|
|
133
|
+
if (fGetAll) {
|
|
134
|
+
Firestore.prototype.getAll = async function (...args) {
|
|
135
|
+
const res = await fGetAll.apply(this, args);
|
|
136
|
+
meterCount(Array.isArray(res) ? res.length : args.length || 1, hintFrom(args[0]));
|
|
137
|
+
return res;
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Test-only: reset the install guard so a suite can re-patch fresh prototypes. */
|
|
142
|
+
export function __resetFirestoreMeterForTests() {
|
|
143
|
+
installed = false;
|
|
144
|
+
}
|
|
145
|
+
//# sourceMappingURL=firestore.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"firestore.js","sourceRoot":"","sources":["../../src/adapters/firestore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,EAAE,WAAW,EAAiB,MAAM,kBAAkB,CAAC;AAE9D,IAAI,SAAS,GAAG,KAAK,CAAC;AAgBtB,0EAA0E;AAC1E,SAAS,eAAe,CAAC,IAAY;IACnC,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IAC9B,MAAM,CAAC,GAAG,KAAK,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC;IACpC,OAAO,CAAC,IAAI,CAAC,IAAI,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;AAC3D,CAAC;AAED;;;;;GAKG;AACH,SAAS,QAAQ,CAAC,MAAW;IAC3B,IAAI,CAAC;QACH,MAAM,CAAC,GAAG,OAAO,MAAM,EAAE,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC;QAC9D,IAAI,CAAC,EAAE,CAAC;YACN,MAAM,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;YAC3C,MAAM,UAAU,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC;YAC9F,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,eAAe,CAAC,CAAC,CAAC,EAAE,CAAC;QACvD,CAAC;QACD,MAAM,EAAE,GAAG,MAAM,EAAE,aAAa,CAAC;QACjC,IAAI,EAAE,EAAE,CAAC;YACP,MAAM,UAAU,GAAG,OAAO,EAAE,CAAC,YAAY,KAAK,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC,YAAY,CAAC,CAAC,CAAC,SAAS,CAAC;YACrF,MAAM,MAAM,GACV,OAAO,EAAE,CAAC,UAAU,EAAE,YAAY,KAAK,QAAQ;gBAC7C,CAAC,CAAC,EAAE,CAAC,UAAU,CAAC,YAAY;gBAC5B,CAAC,CAAC,OAAO,EAAE,CAAC,UAAU,EAAE,QAAQ,KAAK,UAAU;oBAC7C,CAAC,CAAC,MAAM,CAAC,EAAE,CAAC,UAAU,CAAC,QAAQ,EAAE,CAAC;oBAClC,CAAC,CAAC,EAAE,CAAC;YACX,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC,CAAC,eAAe,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC;QACjF,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,gCAAgC;IAClC,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,SAAS,SAAS,CAAC,IAAa,EAAE,IAAe;IAC/C,IAAI,CAAC;QACH,MAAM,IAAI,GAAI,IAAiC,EAAE,IAAI,CAAC;QACtD,WAAW,CAAC,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC;IACzD,CAAC;IAAC,MAAM,CAAC;QACP,iBAAiB;IACnB,CAAC;AACH,CAAC;AACD,SAAS,UAAU,CAAC,CAAS,EAAE,IAAe;IAC5C,IAAI,CAAC;QACH,WAAW,CAAC,CAAC,EAAE,IAAI,CAAC,CAAC;IACvB,CAAC;IAAC,MAAM,CAAC;QACP,iBAAiB;IACnB,CAAC;AACH,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,qBAAqB,CAAC,OAAyB;IAC7D,IAAI,SAAS;QAAE,OAAO;IACtB,SAAS,GAAG,IAAI,CAAC;IACjB,MAAM,EAAE,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAE,SAAS,EAAE,GAAG,OAAO,CAAC;IAErE,8EAA8E;IAC9E,MAAM,IAAI,GAAG,KAAK,EAAE,SAAS,EAAE,GAAG,CAAC;IACnC,IAAI,IAAI,EAAE,CAAC;QACT,KAAM,CAAC,SAAS,CAAC,GAAG,GAAG,KAAK,WAA0B,GAAG,IAAW;YAClE,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;YAC1C,SAAS,CAAC,IAAI,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC;YAChC,OAAO,IAAI,CAAC;QACd,CAAC,CAAC;IACJ,CAAC;IAED,iDAAiD;IACjD,MAAM,IAAI,GAAG,iBAAiB,EAAE,SAAS,EAAE,GAAG,CAAC;IAC/C,IAAI,IAAI,EAAE,CAAC;QACT,iBAAkB,CAAC,SAAS,CAAC,GAAG,GAAG,KAAK,WAA0B,GAAG,IAAW;YAC9E,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;YAC1C,UAAU,CAAC,CAAC,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC;YAC9B,OAAO,IAAI,CAAC;QACd,CAAC,CAAC;IACJ,CAAC;IAED,6DAA6D;IAC7D,MAAM,IAAI,GAAG,WAAW,EAAE,SAAS,EAAE,GAAG,CAAC;IACzC,IAAI,IAAI,EAAE,CAAC;QACT,WAAY,CAAC,SAAS,CAAC,GAAG,GAAG,KAAK,WAA0B,GAAG,IAAW;YACxE,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;YACzC,SAAS,CAAC,GAAG,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YAClC,OAAO,GAAG,CAAC;QACb,CAAC,CAAC;IACJ,CAAC;IAED,kDAAkD;IAClD,MAAM,OAAO,GAAG,WAAW,EAAE,SAAS,EAAE,MAAM,CAAC;IAC/C,IAAI,OAAO,EAAE,CAAC;QACZ,WAAY,CAAC,SAAS,CAAC,MAAM,GAAG,KAAK,WAA0B,GAAG,IAAW;YAC3E,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;YAC5C,UAAU,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,IAAI,CAAC,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YAClF,OAAO,GAAG,CAAC;QACb,CAAC,CAAC;IACJ,CAAC;IAED,iDAAiD;IACjD,MAAM,OAAO,GAAG,SAAS,EAAE,SAAS,EAAE,MAAM,CAAC;IAC7C,IAAI,OAAO,EAAE,CAAC;QACZ,SAAU,CAAC,SAAS,CAAC,MAAM,GAAG,KAAK,WAA0B,GAAG,IAAW;YACzE,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,KAAK,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;YAC5C,UAAU,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,IAAI,CAAC,EAAE,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YAClF,OAAO,GAAG,CAAC;QACb,CAAC,CAAC;IACJ,CAAC;AACH,CAAC;AAED,mFAAmF;AACnF,MAAM,UAAU,6BAA6B;IAC3C,SAAS,GAAG,KAAK,CAAC;AACpB,CAAC"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface CostTag {
|
|
2
|
+
/** Optional coarse grouping (a caller-defined surface name). */
|
|
3
|
+
feature?: string;
|
|
4
|
+
/** The bucket name — what `bucket()` sets. Drives the report's `byLabel`. */
|
|
5
|
+
label?: string;
|
|
6
|
+
}
|
|
7
|
+
/** Run `fn` with `tag` bound for its entire async subtree. */
|
|
8
|
+
export declare function runWithCostTag<T>(tag: CostTag, fn: () => T): T;
|
|
9
|
+
/** Bind a tag for the remainder of the current async context (no closure to wrap). */
|
|
10
|
+
export declare function enterCostTag(tag: CostTag): void;
|
|
11
|
+
/** Refine the live tag in place (e.g. stamp a feature after the boundary). */
|
|
12
|
+
export declare function refineCostTag(patch: Partial<CostTag>): void;
|
|
13
|
+
/** The current tag, or a safe empty default outside any bound context. */
|
|
14
|
+
export declare function currentCostTag(): CostTag;
|
|
15
|
+
/**
|
|
16
|
+
* `bucket(name, fn)` — the headline verb, the `track()` of cost. Run `fn` with
|
|
17
|
+
* every operation inside it attributed to the bucket `name`; the attribution
|
|
18
|
+
* rides the async subtree automatically. The one verb most developers ever touch:
|
|
19
|
+
*
|
|
20
|
+
* await bucket("nightly-export", async () => {
|
|
21
|
+
* const rows = await db.collection("events").where(...).get(); // → "nightly-export"
|
|
22
|
+
* });
|
|
23
|
+
*/
|
|
24
|
+
export declare function bucket<T>(name: string, fn: () => T): T;
|
|
25
|
+
//# sourceMappingURL=cost-context.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cost-context.d.ts","sourceRoot":"","sources":["../src/cost-context.ts"],"names":[],"mappings":"AAcA,MAAM,WAAW,OAAO;IACtB,gEAAgE;IAChE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAKD,8DAA8D;AAC9D,wBAAgB,cAAc,CAAC,CAAC,EAAE,GAAG,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAE9D;AAED,sFAAsF;AACtF,wBAAgB,YAAY,CAAC,GAAG,EAAE,OAAO,GAAG,IAAI,CAE/C;AAED,8EAA8E;AAC9E,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,CAG3D;AAED,0EAA0E;AAC1E,wBAAgB,cAAc,IAAI,OAAO,CAExC;AAED;;;;;;;;GAQG;AACH,wBAAgB,MAAM,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAEtD"}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cost-context — the request-scoped tag every counted operation attributes
|
|
3
|
+
* itself to. Set it ONCE at a boundary (or wrap a path with `bucket()`); it
|
|
4
|
+
* rides Node's AsyncLocalStorage down through every async fan-out, so one
|
|
5
|
+
* handler that triggers 15 reads attributes all 15 to the same bucket — with
|
|
6
|
+
* zero per-call-site work.
|
|
7
|
+
*
|
|
8
|
+
* Generic by design: unlike a hardcoded product taxonomy, the only meaningful
|
|
9
|
+
* field a consumer sets is the free-form `label` (the bucket name). `feature`
|
|
10
|
+
* is an optional coarse grouping if you want one; nothing here is
|
|
11
|
+
* Crossdeck-specific.
|
|
12
|
+
*/
|
|
13
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
14
|
+
const DEFAULT_TAG = {};
|
|
15
|
+
const store = new AsyncLocalStorage();
|
|
16
|
+
/** Run `fn` with `tag` bound for its entire async subtree. */
|
|
17
|
+
export function runWithCostTag(tag, fn) {
|
|
18
|
+
return store.run({ ...tag }, fn);
|
|
19
|
+
}
|
|
20
|
+
/** Bind a tag for the remainder of the current async context (no closure to wrap). */
|
|
21
|
+
export function enterCostTag(tag) {
|
|
22
|
+
store.enterWith({ ...tag });
|
|
23
|
+
}
|
|
24
|
+
/** Refine the live tag in place (e.g. stamp a feature after the boundary). */
|
|
25
|
+
export function refineCostTag(patch) {
|
|
26
|
+
const cur = store.getStore();
|
|
27
|
+
if (cur)
|
|
28
|
+
Object.assign(cur, patch);
|
|
29
|
+
}
|
|
30
|
+
/** The current tag, or a safe empty default outside any bound context. */
|
|
31
|
+
export function currentCostTag() {
|
|
32
|
+
return store.getStore() ?? DEFAULT_TAG;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* `bucket(name, fn)` — the headline verb, the `track()` of cost. Run `fn` with
|
|
36
|
+
* every operation inside it attributed to the bucket `name`; the attribution
|
|
37
|
+
* rides the async subtree automatically. The one verb most developers ever touch:
|
|
38
|
+
*
|
|
39
|
+
* await bucket("nightly-export", async () => {
|
|
40
|
+
* const rows = await db.collection("events").where(...).get(); // → "nightly-export"
|
|
41
|
+
* });
|
|
42
|
+
*/
|
|
43
|
+
export function bucket(name, fn) {
|
|
44
|
+
return runWithCostTag({ ...currentCostTag(), label: name }, fn);
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=cost-context.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cost-context.js","sourceRoot":"","sources":["../src/cost-context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AASrD,MAAM,WAAW,GAAY,EAAE,CAAC;AAChC,MAAM,KAAK,GAAG,IAAI,iBAAiB,EAAW,CAAC;AAE/C,8DAA8D;AAC9D,MAAM,UAAU,cAAc,CAAI,GAAY,EAAE,EAAW;IACzD,OAAO,KAAK,CAAC,GAAG,CAAC,EAAE,GAAG,GAAG,EAAE,EAAE,EAAE,CAAC,CAAC;AACnC,CAAC;AAED,sFAAsF;AACtF,MAAM,UAAU,YAAY,CAAC,GAAY;IACvC,KAAK,CAAC,SAAS,CAAC,EAAE,GAAG,GAAG,EAAE,CAAC,CAAC;AAC9B,CAAC;AAED,8EAA8E;AAC9E,MAAM,UAAU,aAAa,CAAC,KAAuB;IACnD,MAAM,GAAG,GAAG,KAAK,CAAC,QAAQ,EAAE,CAAC;IAC7B,IAAI,GAAG;QAAE,MAAM,CAAC,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;AACrC,CAAC;AAED,0EAA0E;AAC1E,MAAM,UAAU,cAAc;IAC5B,OAAO,KAAK,CAAC,QAAQ,EAAE,IAAI,WAAW,CAAC;AACzC,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,MAAM,CAAI,IAAY,EAAE,EAAW;IACjD,OAAO,cAAc,CAAC,EAAE,GAAG,cAAc,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,CAAC;AAClE,CAAC"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Sink } from "./sink.js";
|
|
2
|
+
export type OpType = "read" | "write" | "delete";
|
|
3
|
+
/** Optional read-site hint — the collection touched, derived at the trap from the
|
|
4
|
+
* path. Lets an UNtagged read cascade to `col:<collection>` instead of vanishing. */
|
|
5
|
+
export interface CostHint {
|
|
6
|
+
collection?: string;
|
|
7
|
+
projectId?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface MeterConfig {
|
|
10
|
+
sink: Sink;
|
|
11
|
+
flushIntervalMs?: number;
|
|
12
|
+
onError?: (e: unknown) => void;
|
|
13
|
+
}
|
|
14
|
+
/** Point the meter at a sink. Called by `init()`; pass your own sink to self-host. */
|
|
15
|
+
export declare function configureMeter(config: MeterConfig): void;
|
|
16
|
+
/** Count `n` ops of `op` against the live tag. Never throws. */
|
|
17
|
+
export declare function recordFirestore(op: OpType, n: number, hint?: CostHint): void;
|
|
18
|
+
/** Firestore bills a minimum of one read even for an empty result, so 0 counts as 1. */
|
|
19
|
+
export declare function recordReads(n: number, hint?: CostHint): void;
|
|
20
|
+
export declare function recordWrites(n?: number): void;
|
|
21
|
+
export declare function recordDeletes(n?: number): void;
|
|
22
|
+
/**
|
|
23
|
+
* Coalesce the buffer into one report per UTC day and hand each to the Sink.
|
|
24
|
+
* Snapshots + clears up front so concurrent records land in the next window.
|
|
25
|
+
* Never throws; a sink failure drops that window (surfaced via `onError`).
|
|
26
|
+
*/
|
|
27
|
+
export declare function flush(): Promise<void>;
|
|
28
|
+
//# sourceMappingURL=cost-meter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cost-meter.d.ts","sourceRoot":"","sources":["../src/cost-meter.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,IAAI,EAA2B,MAAM,WAAW,CAAC;AAE/D,MAAM,MAAM,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,QAAQ,CAAC;AAEjD;sFACsF;AACtF,MAAM,WAAW,QAAQ;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAmBD,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,IAAI,CAAC;IACX,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,OAAO,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;CAChC;AAED,sFAAsF;AACtF,wBAAgB,cAAc,CAAC,MAAM,EAAE,WAAW,GAAG,IAAI,CAIxD;AAeD,gEAAgE;AAChE,wBAAgB,eAAe,CAAC,EAAE,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,QAAQ,GAAG,IAAI,CAkB5E;AAED,wFAAwF;AACxF,wBAAgB,WAAW,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,QAAQ,GAAG,IAAI,CAE5D;AACD,wBAAgB,YAAY,CAAC,CAAC,SAAI,GAAG,IAAI,CAExC;AACD,wBAAgB,aAAa,CAAC,CAAC,SAAI,GAAG,IAAI,CAEzC;AAOD;;;;GAIG;AACH,wBAAsB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CA2C3C"}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cost-meter — counts operations against the ambient tag and flushes them to the
|
|
3
|
+
* configured Sink cheaply.
|
|
4
|
+
*
|
|
5
|
+
* LOW-OVERHEAD CONTRACT (the thing that warns you about reads must not run them
|
|
6
|
+
* up): counts accumulate in an in-memory buffer and flush periodically — NEVER one
|
|
7
|
+
* network call per counted operation. A flush coalesces the whole window into one
|
|
8
|
+
* report per UTC day and hands it to the Sink. At steady state that is ~1 small
|
|
9
|
+
* request a minute, regardless of how many ops you served.
|
|
10
|
+
*
|
|
11
|
+
* BEST-EFFORT CONTRACT: metering must never throw into your code. Every recorder
|
|
12
|
+
* swallows its own errors; a failed flush drops that window's counts (surfaced via
|
|
13
|
+
* `onError` if you pass one) rather than disturbing the app.
|
|
14
|
+
*/
|
|
15
|
+
import { currentCostTag } from "./cost-context.js";
|
|
16
|
+
// NUL separator — a bucket/collection name can contain almost anything except
|
|
17
|
+
// this, so the key never collides with a name that has a "|" or ":" in it.
|
|
18
|
+
const SEP = "\u001f"; // ASCII Unit Separator
|
|
19
|
+
/** key = date <NUL> op <NUL> label → count */
|
|
20
|
+
const labelBuffer = new Map();
|
|
21
|
+
/** key = date <NUL> op <NUL> hour → count */
|
|
22
|
+
const hourBuffer = new Map();
|
|
23
|
+
let sink = null;
|
|
24
|
+
let flushIntervalMs = 60_000;
|
|
25
|
+
let onError = null;
|
|
26
|
+
let timer = null;
|
|
27
|
+
let flushing = false;
|
|
28
|
+
/** Safety valve — flush early if a burst fills the buffer between intervals. */
|
|
29
|
+
const MAX_BUFFER_KEYS = 5_000;
|
|
30
|
+
/** Point the meter at a sink. Called by `init()`; pass your own sink to self-host. */
|
|
31
|
+
export function configureMeter(config) {
|
|
32
|
+
sink = config.sink;
|
|
33
|
+
if (config.flushIntervalMs && config.flushIntervalMs > 0)
|
|
34
|
+
flushIntervalMs = config.flushIntervalMs;
|
|
35
|
+
onError = config.onError ?? null;
|
|
36
|
+
}
|
|
37
|
+
const utcDate = () => new Date().toISOString().slice(0, 10);
|
|
38
|
+
const utcHour = () => new Date().toISOString().slice(11, 13);
|
|
39
|
+
function ensureFlushLoop() {
|
|
40
|
+
if (timer)
|
|
41
|
+
return;
|
|
42
|
+
timer = setInterval(() => void flush(), flushIntervalMs);
|
|
43
|
+
// Don't keep the event loop alive just for metering.
|
|
44
|
+
timer.unref?.();
|
|
45
|
+
// Flush the last window on shutdown.
|
|
46
|
+
process.once?.("SIGTERM", () => void flush());
|
|
47
|
+
process.once?.("beforeExit", () => void flush());
|
|
48
|
+
}
|
|
49
|
+
/** Count `n` ops of `op` against the live tag. Never throws. */
|
|
50
|
+
export function recordFirestore(op, n, hint) {
|
|
51
|
+
try {
|
|
52
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
53
|
+
return;
|
|
54
|
+
const t = currentCostTag();
|
|
55
|
+
const date = utcDate();
|
|
56
|
+
// CASCADE — every op gets a label, by design (no blind spots): the bucket name
|
|
57
|
+
// wins; else the collection it actually touched (`col:posts`); else
|
|
58
|
+
// "uncategorized" as a loud last resort. A read is never invisible.
|
|
59
|
+
const label = t.label || (hint?.collection ? `col:${hint.collection}` : "uncategorized");
|
|
60
|
+
const lk = date + SEP + op + SEP + label;
|
|
61
|
+
labelBuffer.set(lk, (labelBuffer.get(lk) ?? 0) + n);
|
|
62
|
+
const hk = date + SEP + op + SEP + utcHour();
|
|
63
|
+
hourBuffer.set(hk, (hourBuffer.get(hk) ?? 0) + n);
|
|
64
|
+
ensureFlushLoop();
|
|
65
|
+
if (labelBuffer.size + hourBuffer.size > MAX_BUFFER_KEYS)
|
|
66
|
+
void flush();
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* metering is best-effort — never disturb the caller */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Firestore bills a minimum of one read even for an empty result, so 0 counts as 1. */
|
|
73
|
+
export function recordReads(n, hint) {
|
|
74
|
+
recordFirestore("read", Math.max(n, 1), hint);
|
|
75
|
+
}
|
|
76
|
+
export function recordWrites(n = 1) {
|
|
77
|
+
recordFirestore("write", n);
|
|
78
|
+
}
|
|
79
|
+
export function recordDeletes(n = 1) {
|
|
80
|
+
recordFirestore("delete", n);
|
|
81
|
+
}
|
|
82
|
+
function add(target, key, op, n) {
|
|
83
|
+
const bag = (target[key] ??= {});
|
|
84
|
+
bag[op] = (bag[op] ?? 0) + n;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Coalesce the buffer into one report per UTC day and hand each to the Sink.
|
|
88
|
+
* Snapshots + clears up front so concurrent records land in the next window.
|
|
89
|
+
* Never throws; a sink failure drops that window (surfaced via `onError`).
|
|
90
|
+
*/
|
|
91
|
+
export async function flush() {
|
|
92
|
+
if (flushing)
|
|
93
|
+
return;
|
|
94
|
+
// Not configured (init not called) — drop, don't grow unbounded.
|
|
95
|
+
if (!sink) {
|
|
96
|
+
labelBuffer.clear();
|
|
97
|
+
hourBuffer.clear();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (labelBuffer.size === 0 && hourBuffer.size === 0)
|
|
101
|
+
return;
|
|
102
|
+
flushing = true;
|
|
103
|
+
const labels = new Map(labelBuffer);
|
|
104
|
+
const hours = new Map(hourBuffer);
|
|
105
|
+
labelBuffer.clear();
|
|
106
|
+
hourBuffer.clear();
|
|
107
|
+
try {
|
|
108
|
+
const byDate = new Map();
|
|
109
|
+
const reportFor = (date) => {
|
|
110
|
+
let r = byDate.get(date);
|
|
111
|
+
if (!r) {
|
|
112
|
+
r = { date, byLabel: {}, byHour: {} };
|
|
113
|
+
byDate.set(date, r);
|
|
114
|
+
}
|
|
115
|
+
return r;
|
|
116
|
+
};
|
|
117
|
+
for (const [k, n] of labels) {
|
|
118
|
+
const [date, op, label] = k.split(SEP);
|
|
119
|
+
add(reportFor(date).byLabel, label, op, n);
|
|
120
|
+
}
|
|
121
|
+
for (const [k, n] of hours) {
|
|
122
|
+
const [date, op, hour] = k.split(SEP);
|
|
123
|
+
add(reportFor(date).byHour, hour, op, n);
|
|
124
|
+
}
|
|
125
|
+
for (const report of byDate.values()) {
|
|
126
|
+
await sink.flush(report);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
// Drop this window rather than risk a partial/double report on retry.
|
|
131
|
+
onError?.(e);
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
flushing = false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
//# sourceMappingURL=cost-meter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cost-meter.js","sourceRoot":"","sources":["../src/cost-meter.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cross-deck/buckets — know exactly what every database read costs you, and who
|
|
3
|
+
* caused it. A tiny, never-throws collector for Firestore.
|
|
4
|
+
*
|
|
5
|
+
* The whole footprint a consumer sees:
|
|
6
|
+
* 1. init({ apiKey, firestore }) — configure once, install the trap once
|
|
7
|
+
* 2. bucket(name, fn) — name the read paths that matter
|
|
8
|
+
* 3. (the dashboard shows the rest — and names the ones you haven't yet)
|
|
9
|
+
*/
|
|
10
|
+
import { type MeterConfig } from "./cost-meter.js";
|
|
11
|
+
import { type Sink } from "./sink.js";
|
|
12
|
+
import { type FirestoreClasses } from "./adapters/firestore.js";
|
|
13
|
+
export interface InitOptions {
|
|
14
|
+
/** The project's `cd_sk_` SECRET key. Server-to-server only — never a browser key. */
|
|
15
|
+
apiKey: string;
|
|
16
|
+
/**
|
|
17
|
+
* Pass the namespace from `firebase-admin/firestore` to auto-install the read
|
|
18
|
+
* trap (recommended — this is what makes every read count with no per-call work).
|
|
19
|
+
* Omit it if you'd rather call `installFirestoreMeter()` yourself, or you only
|
|
20
|
+
* use the manual `recordReads()` recorders.
|
|
21
|
+
*/
|
|
22
|
+
firestore?: FirestoreClasses;
|
|
23
|
+
/** Override the report endpoint (defaults to Crossdeck's ingest). */
|
|
24
|
+
endpoint?: string;
|
|
25
|
+
/** How often to flush coalesced counts (ms). Default 60_000. */
|
|
26
|
+
flushIntervalMs?: number;
|
|
27
|
+
/** Bring your own sink (self-host the rollups). Defaults to reporting up to Crossdeck. */
|
|
28
|
+
sink?: Sink;
|
|
29
|
+
/** Notified when a flush fails, so a dropped window is never silent. Best-effort. */
|
|
30
|
+
onError?: MeterConfig["onError"];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Configure Buckets once, at process start. Points the meter at a sink (Crossdeck's
|
|
34
|
+
* ingest by default) and — if you pass `firestore` — installs the universal read
|
|
35
|
+
* trap so every read counts automatically.
|
|
36
|
+
*/
|
|
37
|
+
export declare function init(options: InitOptions): void;
|
|
38
|
+
/** Alias — reads well next to `bucket()` at a call site. */
|
|
39
|
+
export { init as initBuckets };
|
|
40
|
+
export { bucket, runWithCostTag, enterCostTag, refineCostTag, currentCostTag, type CostTag, } from "./cost-context.js";
|
|
41
|
+
export { recordReads, recordWrites, recordDeletes, flush, type CostHint, type OpType, type MeterConfig, } from "./cost-meter.js";
|
|
42
|
+
export { installFirestoreMeter, type FirestoreClasses } from "./adapters/firestore.js";
|
|
43
|
+
export { ReportSink, type Sink, type BucketsReport, type OpCounts, type ReportSinkConfig } from "./sink.js";
|
|
44
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AACnE,OAAO,EAAc,KAAK,IAAI,EAAE,MAAM,WAAW,CAAC;AAClD,OAAO,EAAyB,KAAK,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAEvF,MAAM,WAAW,WAAW;IAC1B,sFAAsF;IACtF,MAAM,EAAE,MAAM,CAAC;IACf;;;;;OAKG;IACH,SAAS,CAAC,EAAE,gBAAgB,CAAC;IAC7B,qEAAqE;IACrE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gEAAgE;IAChE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,0FAA0F;IAC1F,IAAI,CAAC,EAAE,IAAI,CAAC;IACZ,qFAAqF;IACrF,OAAO,CAAC,EAAE,WAAW,CAAC,SAAS,CAAC,CAAC;CAClC;AAED;;;;GAIG;AACH,wBAAgB,IAAI,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,CAI/C;AAED,4DAA4D;AAC5D,OAAO,EAAE,IAAI,IAAI,WAAW,EAAE,CAAC;AAG/B,OAAO,EACL,MAAM,EACN,cAAc,EACd,YAAY,EACZ,aAAa,EACb,cAAc,EACd,KAAK,OAAO,GACb,MAAM,mBAAmB,CAAC;AAG3B,OAAO,EACL,WAAW,EACX,YAAY,EACZ,aAAa,EACb,KAAK,EACL,KAAK,QAAQ,EACb,KAAK,MAAM,EACX,KAAK,WAAW,GACjB,MAAM,iBAAiB,CAAC;AAGzB,OAAO,EAAE,qBAAqB,EAAE,KAAK,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAGvF,OAAO,EAAE,UAAU,EAAE,KAAK,IAAI,EAAE,KAAK,aAAa,EAAE,KAAK,QAAQ,EAAE,KAAK,gBAAgB,EAAE,MAAM,WAAW,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cross-deck/buckets — know exactly what every database read costs you, and who
|
|
3
|
+
* caused it. A tiny, never-throws collector for Firestore.
|
|
4
|
+
*
|
|
5
|
+
* The whole footprint a consumer sees:
|
|
6
|
+
* 1. init({ apiKey, firestore }) — configure once, install the trap once
|
|
7
|
+
* 2. bucket(name, fn) — name the read paths that matter
|
|
8
|
+
* 3. (the dashboard shows the rest — and names the ones you haven't yet)
|
|
9
|
+
*/
|
|
10
|
+
import { configureMeter } from "./cost-meter.js";
|
|
11
|
+
import { ReportSink } from "./sink.js";
|
|
12
|
+
import { installFirestoreMeter } from "./adapters/firestore.js";
|
|
13
|
+
/**
|
|
14
|
+
* Configure Buckets once, at process start. Points the meter at a sink (Crossdeck's
|
|
15
|
+
* ingest by default) and — if you pass `firestore` — installs the universal read
|
|
16
|
+
* trap so every read counts automatically.
|
|
17
|
+
*/
|
|
18
|
+
export function init(options) {
|
|
19
|
+
const sink = options.sink ?? new ReportSink({ apiKey: options.apiKey, endpoint: options.endpoint });
|
|
20
|
+
configureMeter({ sink, flushIntervalMs: options.flushIntervalMs, onError: options.onError });
|
|
21
|
+
if (options.firestore)
|
|
22
|
+
installFirestoreMeter(options.firestore);
|
|
23
|
+
}
|
|
24
|
+
/** Alias — reads well next to `bucket()` at a call site. */
|
|
25
|
+
export { init as initBuckets };
|
|
26
|
+
// The headline verb + the lower-level tag controls it is sugar over.
|
|
27
|
+
export { bucket, runWithCostTag, enterCostTag, refineCostTag, currentCostTag, } from "./cost-context.js";
|
|
28
|
+
// Manual recorders (for non-Firestore ops, or when you don't install the trap).
|
|
29
|
+
export { recordReads, recordWrites, recordDeletes, flush, } from "./cost-meter.js";
|
|
30
|
+
// The trap (the only datastore adapter today) + its class shape.
|
|
31
|
+
export { installFirestoreMeter } from "./adapters/firestore.js";
|
|
32
|
+
// The sink seam — for self-hosting rollups instead of reporting to Crossdeck.
|
|
33
|
+
export { ReportSink } from "./sink.js";
|
|
34
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,cAAc,EAAoB,MAAM,iBAAiB,CAAC;AACnE,OAAO,EAAE,UAAU,EAAa,MAAM,WAAW,CAAC;AAClD,OAAO,EAAE,qBAAqB,EAAyB,MAAM,yBAAyB,CAAC;AAsBvF;;;;GAIG;AACH,MAAM,UAAU,IAAI,CAAC,OAAoB;IACvC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,IAAI,UAAU,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,QAAQ,EAAE,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC;IACpG,cAAc,CAAC,EAAE,IAAI,EAAE,eAAe,EAAE,OAAO,CAAC,eAAe,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;IAC7F,IAAI,OAAO,CAAC,SAAS;QAAE,qBAAqB,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;AAClE,CAAC;AAED,4DAA4D;AAC5D,OAAO,EAAE,IAAI,IAAI,WAAW,EAAE,CAAC;AAE/B,qEAAqE;AACrE,OAAO,EACL,MAAM,EACN,cAAc,EACd,YAAY,EACZ,aAAa,EACb,cAAc,GAEf,MAAM,mBAAmB,CAAC;AAE3B,gFAAgF;AAChF,OAAO,EACL,WAAW,EACX,YAAY,EACZ,aAAa,EACb,KAAK,GAIN,MAAM,iBAAiB,CAAC;AAEzB,iEAAiE;AACjE,OAAO,EAAE,qBAAqB,EAAyB,MAAM,yBAAyB,CAAC;AAEvF,8EAA8E;AAC9E,OAAO,EAAE,UAAU,EAAuE,MAAM,WAAW,CAAC"}
|
package/dist/sink.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sink — where the meter sends a coalesced rollup, and the wire shape it sends.
|
|
3
|
+
*
|
|
4
|
+
* Abstracting the sink is what makes Buckets storage-agnostic: the meter never
|
|
5
|
+
* knows where counts go. The DEFAULT sink (`ReportSink`) reports up to Crossdeck's
|
|
6
|
+
* ingest endpoint so the numbers surface on your Crossdeck dashboard. A team that
|
|
7
|
+
* wants to self-host can implement `Sink` against anything (Postgres, a file, your
|
|
8
|
+
* own API) without touching the meter.
|
|
9
|
+
*/
|
|
10
|
+
export interface OpCounts {
|
|
11
|
+
read?: number;
|
|
12
|
+
write?: number;
|
|
13
|
+
delete?: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* One coalesced report — the wire contract (see docs/ROLLUP_SCHEMA.md). The meter
|
|
17
|
+
* produces one of these per UTC day in a flush window (usually exactly one).
|
|
18
|
+
*/
|
|
19
|
+
export interface BucketsReport {
|
|
20
|
+
/** UTC day "YYYY-MM-DD". */
|
|
21
|
+
date: string;
|
|
22
|
+
/** bucket name → counts. The heart of the report. */
|
|
23
|
+
byLabel: Record<string, OpCounts>;
|
|
24
|
+
/** UTC hour "HH" → counts, for the hourly "did my fix land this hour?" view. */
|
|
25
|
+
byHour?: Record<string, OpCounts>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A destination for coalesced rollups. `flush` MAY throw on failure — the meter
|
|
29
|
+
* catches it, drops that one window, and never lets it reach your app.
|
|
30
|
+
*/
|
|
31
|
+
export interface Sink {
|
|
32
|
+
flush(report: BucketsReport): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
export interface ReportSinkConfig {
|
|
35
|
+
/** The project's `cd_sk_` secret key. Server-to-server only. */
|
|
36
|
+
apiKey: string;
|
|
37
|
+
/** Defaults to https://api.cross-deck.com/v1/buckets/report */
|
|
38
|
+
endpoint?: string;
|
|
39
|
+
/** Request timeout (ms); a slow Crossdeck must never stall your flush. */
|
|
40
|
+
timeoutMs?: number;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The default sink: POST one coalesced rollup to Crossdeck's ingest endpoint.
|
|
44
|
+
* The ingest folds it into the day's maintained doc with `increment`, so many
|
|
45
|
+
* reports a minute coalesce safely. This path does ZERO database reads — it sends
|
|
46
|
+
* a summary, it does not read. Throws on a non-202 so the meter can log/drop the
|
|
47
|
+
* window; the meter guarantees it never reaches your app.
|
|
48
|
+
*/
|
|
49
|
+
export declare class ReportSink implements Sink {
|
|
50
|
+
private readonly endpoint;
|
|
51
|
+
private readonly apiKey;
|
|
52
|
+
private readonly timeoutMs;
|
|
53
|
+
constructor(config: ReportSinkConfig);
|
|
54
|
+
flush(report: BucketsReport): Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=sink.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sink.d.ts","sourceRoot":"","sources":["../src/sink.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,WAAW,QAAQ;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;GAGG;AACH,MAAM,WAAW,aAAa;IAC5B,4BAA4B;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IAClC,gFAAgF;IAChF,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;CACnC;AAED;;;GAGG;AACH,MAAM,WAAW,IAAI;IACnB,KAAK,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7C;AAED,MAAM,WAAW,gBAAgB;IAC/B,gEAAgE;IAChE,MAAM,EAAE,MAAM,CAAC;IACf,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,0EAA0E;IAC1E,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAID;;;;;;GAMG;AACH,qBAAa,UAAW,YAAW,IAAI;IACrC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;gBAEvB,MAAM,EAAE,gBAAgB;IAM9B,KAAK,CAAC,MAAM,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;CAclD"}
|
package/dist/sink.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sink — where the meter sends a coalesced rollup, and the wire shape it sends.
|
|
3
|
+
*
|
|
4
|
+
* Abstracting the sink is what makes Buckets storage-agnostic: the meter never
|
|
5
|
+
* knows where counts go. The DEFAULT sink (`ReportSink`) reports up to Crossdeck's
|
|
6
|
+
* ingest endpoint so the numbers surface on your Crossdeck dashboard. A team that
|
|
7
|
+
* wants to self-host can implement `Sink` against anything (Postgres, a file, your
|
|
8
|
+
* own API) without touching the meter.
|
|
9
|
+
*/
|
|
10
|
+
const DEFAULT_ENDPOINT = "https://api.cross-deck.com/v1/buckets/report";
|
|
11
|
+
/**
|
|
12
|
+
* The default sink: POST one coalesced rollup to Crossdeck's ingest endpoint.
|
|
13
|
+
* The ingest folds it into the day's maintained doc with `increment`, so many
|
|
14
|
+
* reports a minute coalesce safely. This path does ZERO database reads — it sends
|
|
15
|
+
* a summary, it does not read. Throws on a non-202 so the meter can log/drop the
|
|
16
|
+
* window; the meter guarantees it never reaches your app.
|
|
17
|
+
*/
|
|
18
|
+
export class ReportSink {
|
|
19
|
+
endpoint;
|
|
20
|
+
apiKey;
|
|
21
|
+
timeoutMs;
|
|
22
|
+
constructor(config) {
|
|
23
|
+
this.endpoint = config.endpoint ?? DEFAULT_ENDPOINT;
|
|
24
|
+
this.apiKey = config.apiKey;
|
|
25
|
+
this.timeoutMs = config.timeoutMs ?? 5000;
|
|
26
|
+
}
|
|
27
|
+
async flush(report) {
|
|
28
|
+
const res = await fetch(this.endpoint, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
31
|
+
headers: {
|
|
32
|
+
"content-type": "application/json",
|
|
33
|
+
authorization: `Bearer ${this.apiKey}`,
|
|
34
|
+
},
|
|
35
|
+
body: JSON.stringify(report),
|
|
36
|
+
});
|
|
37
|
+
if (res.status !== 202) {
|
|
38
|
+
throw new Error(`Buckets report rejected: HTTP ${res.status}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=sink.js.map
|
package/dist/sink.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sink.js","sourceRoot":"","sources":["../src/sink.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAsCH,MAAM,gBAAgB,GAAG,8CAA8C,CAAC;AAExE;;;;;;GAMG;AACH,MAAM,OAAO,UAAU;IACJ,QAAQ,CAAS;IACjB,MAAM,CAAS;IACf,SAAS,CAAS;IAEnC,YAAY,MAAwB;QAClC,IAAI,CAAC,QAAQ,GAAG,MAAM,CAAC,QAAQ,IAAI,gBAAgB,CAAC;QACpD,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;QAC5B,IAAI,CAAC,SAAS,GAAG,MAAM,CAAC,SAAS,IAAI,IAAI,CAAC;IAC5C,CAAC;IAED,KAAK,CAAC,KAAK,CAAC,MAAqB;QAC/B,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,IAAI,CAAC,QAAQ,EAAE;YACrC,MAAM,EAAE,MAAM;YACd,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC;YAC3C,OAAO,EAAE;gBACP,cAAc,EAAE,kBAAkB;gBAClC,aAAa,EAAE,UAAU,IAAI,CAAC,MAAM,EAAE;aACvC;YACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC;SAC7B,CAAC,CAAC;QACH,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG,EAAE,CAAC;YACvB,MAAM,IAAI,KAAK,CAAC,iCAAiC,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC;QACjE,CAAC;IACH,CAAC;CACF"}
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cross-deck/buckets",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Know exactly what every database read costs you — and who caused it. A tiny, never-throws read-cost collector for Firestore.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Crossdeck",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"import": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./adapters/firestore": {
|
|
16
|
+
"types": "./dist/adapters/firestore.d.ts",
|
|
17
|
+
"import": "./dist/adapters/firestore.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc -p tsconfig.json",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest",
|
|
30
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
31
|
+
"prepare": "npm run build",
|
|
32
|
+
"prepublishOnly": "npm run build"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"firestore",
|
|
36
|
+
"firebase",
|
|
37
|
+
"cost",
|
|
38
|
+
"observability",
|
|
39
|
+
"database",
|
|
40
|
+
"reads",
|
|
41
|
+
"attribution",
|
|
42
|
+
"finops",
|
|
43
|
+
"crossdeck"
|
|
44
|
+
],
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/Crossdeckhq/buckets-oss.git"
|
|
48
|
+
},
|
|
49
|
+
"homepage": "https://github.com/Crossdeckhq/buckets-oss#readme",
|
|
50
|
+
"bugs": {
|
|
51
|
+
"url": "https://github.com/Crossdeckhq/buckets-oss/issues"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"firebase-admin": ">=11"
|
|
55
|
+
},
|
|
56
|
+
"peerDependenciesMeta": {
|
|
57
|
+
"firebase-admin": {
|
|
58
|
+
"optional": true
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@types/node": "^20.11.0",
|
|
63
|
+
"firebase-admin": "^12.0.0",
|
|
64
|
+
"typescript": "^5.4.0",
|
|
65
|
+
"vitest": "^1.6.0"
|
|
66
|
+
},
|
|
67
|
+
"engines": {
|
|
68
|
+
"node": ">=18"
|
|
69
|
+
}
|
|
70
|
+
}
|