@cross-deck/buckets 0.1.0 → 0.1.2
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 +7 -3
- package/dist/index.d.mts +179 -0
- package/dist/index.d.ts +147 -12
- package/dist/index.js +249 -33
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +236 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +7 -9
- package/dist/adapters/firestore.d.ts +0 -41
- package/dist/adapters/firestore.d.ts.map +0 -1
- package/dist/adapters/firestore.js +0 -145
- package/dist/adapters/firestore.js.map +0 -1
- package/dist/cost-context.d.ts +0 -25
- package/dist/cost-context.d.ts.map +0 -1
- package/dist/cost-context.js +0 -46
- package/dist/cost-context.js.map +0 -1
- package/dist/cost-meter.d.ts +0 -28
- package/dist/cost-meter.d.ts.map +0 -1
- package/dist/cost-meter.js +0 -137
- package/dist/cost-meter.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/sink.d.ts +0 -56
- package/dist/sink.d.ts.map +0 -1
- package/dist/sink.js +0 -42
- package/dist/sink.js.map +0 -1
package/README.md
CHANGED
|
@@ -399,9 +399,13 @@ That's the line between the open primitive and the product built on it.
|
|
|
399
399
|
## FAQ
|
|
400
400
|
|
|
401
401
|
**Is this really free, or is there a catch?**
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
402
|
+
No catch — and that includes Crossdeck itself. The collector and the schema are
|
|
403
|
+
MIT, free forever; **and seeing your numbers on Crossdeck is free too**, on a
|
|
404
|
+
genuinely generous free tier. We never charge you to watch your own read costs —
|
|
405
|
+
there's no trial clock, no paywall, no "upgrade to see your data." This isn't a
|
|
406
|
+
funnel dressed up as open source. Your rollups are yours — in your datastore,
|
|
407
|
+
readable by anything, with or without us. Crossdeck earns its keep on the broader
|
|
408
|
+
platform you can grow into, never on locking up the cost data you already own.
|
|
405
409
|
|
|
406
410
|
**Won't patching the SDK slow down my reads?**
|
|
407
411
|
No. The overhead is one in-memory map increment per read. No I/O is added to the
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
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
|
+
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
|
+
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
|
+
interface Sink {
|
|
32
|
+
flush(report: BucketsReport): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
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
|
+
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
|
+
|
|
57
|
+
type OpType = "read" | "write" | "delete";
|
|
58
|
+
/** Optional read-site hint — the collection touched, derived at the trap from the
|
|
59
|
+
* path. Lets an UNtagged read cascade to `col:<collection>` instead of vanishing. */
|
|
60
|
+
interface CostHint {
|
|
61
|
+
collection?: string;
|
|
62
|
+
projectId?: string;
|
|
63
|
+
}
|
|
64
|
+
interface MeterConfig {
|
|
65
|
+
sink: Sink;
|
|
66
|
+
flushIntervalMs?: number;
|
|
67
|
+
onError?: (e: unknown) => void;
|
|
68
|
+
}
|
|
69
|
+
/** Firestore bills a minimum of one read even for an empty result, so 0 counts as 1. */
|
|
70
|
+
declare function recordReads(n: number, hint?: CostHint): void;
|
|
71
|
+
declare function recordWrites(n?: number): void;
|
|
72
|
+
declare function recordDeletes(n?: number): void;
|
|
73
|
+
/**
|
|
74
|
+
* Coalesce the buffer into one report per UTC day and hand each to the Sink.
|
|
75
|
+
* Snapshots + clears up front so concurrent records land in the next window.
|
|
76
|
+
* Never throws; a sink failure drops that window (surfaced via `onError`).
|
|
77
|
+
*/
|
|
78
|
+
declare function flush(): Promise<void>;
|
|
79
|
+
|
|
80
|
+
type AnyFn = (...args: any[]) => any;
|
|
81
|
+
/**
|
|
82
|
+
* The firebase-admin Firestore classes to patch. Pass the module namespace from
|
|
83
|
+
* `firebase-admin/firestore` — only the prototypes present are patched.
|
|
84
|
+
*/
|
|
85
|
+
interface FirestoreClasses {
|
|
86
|
+
Query?: {
|
|
87
|
+
prototype: {
|
|
88
|
+
get?: AnyFn;
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
DocumentReference?: {
|
|
92
|
+
prototype: {
|
|
93
|
+
get?: AnyFn;
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
Transaction?: {
|
|
97
|
+
prototype: {
|
|
98
|
+
get?: AnyFn;
|
|
99
|
+
getAll?: AnyFn;
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
Firestore?: {
|
|
103
|
+
prototype: {
|
|
104
|
+
getAll?: AnyFn;
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Install the universal read meter on the firebase-admin Firestore classes. Call
|
|
110
|
+
* ONCE at process start, before any reads. Pass the namespace from
|
|
111
|
+
* `firebase-admin/firestore` so the exact prototypes the SDK uses are patched:
|
|
112
|
+
*
|
|
113
|
+
* import * as Firestore from "firebase-admin/firestore";
|
|
114
|
+
* installFirestoreMeter(Firestore);
|
|
115
|
+
*/
|
|
116
|
+
declare function installFirestoreMeter(classes: FirestoreClasses): void;
|
|
117
|
+
|
|
118
|
+
interface CostTag {
|
|
119
|
+
/** Optional coarse grouping (a caller-defined surface name). */
|
|
120
|
+
feature?: string;
|
|
121
|
+
/** The bucket name — what `bucket()` sets. Drives the report's `byLabel`. */
|
|
122
|
+
label?: string;
|
|
123
|
+
}
|
|
124
|
+
/** Run `fn` with `tag` bound for its entire async subtree. */
|
|
125
|
+
declare function runWithCostTag<T>(tag: CostTag, fn: () => T): T;
|
|
126
|
+
/** Bind a tag for the remainder of the current async context (no closure to wrap). */
|
|
127
|
+
declare function enterCostTag(tag: CostTag): void;
|
|
128
|
+
/** Refine the live tag in place (e.g. stamp a feature after the boundary). */
|
|
129
|
+
declare function refineCostTag(patch: Partial<CostTag>): void;
|
|
130
|
+
/** The current tag, or a safe empty default outside any bound context. */
|
|
131
|
+
declare function currentCostTag(): CostTag;
|
|
132
|
+
/**
|
|
133
|
+
* `bucket(name, fn)` — the headline verb, the `track()` of cost. Run `fn` with
|
|
134
|
+
* every operation inside it attributed to the bucket `name`; the attribution
|
|
135
|
+
* rides the async subtree automatically. The one verb most developers ever touch:
|
|
136
|
+
*
|
|
137
|
+
* await bucket("nightly-export", async () => {
|
|
138
|
+
* const rows = await db.collection("events").where(...).get(); // → "nightly-export"
|
|
139
|
+
* });
|
|
140
|
+
*/
|
|
141
|
+
declare function bucket<T>(name: string, fn: () => T): T;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @cross-deck/buckets — know exactly what every database read costs you, and who
|
|
145
|
+
* caused it. A tiny, never-throws collector for Firestore.
|
|
146
|
+
*
|
|
147
|
+
* The whole footprint a consumer sees:
|
|
148
|
+
* 1. init({ apiKey, firestore }) — configure once, install the trap once
|
|
149
|
+
* 2. bucket(name, fn) — name the read paths that matter
|
|
150
|
+
* 3. (the dashboard shows the rest — and names the ones you haven't yet)
|
|
151
|
+
*/
|
|
152
|
+
|
|
153
|
+
interface InitOptions {
|
|
154
|
+
/** The project's `cd_sk_` SECRET key. Server-to-server only — never a browser key. */
|
|
155
|
+
apiKey: string;
|
|
156
|
+
/**
|
|
157
|
+
* Pass the namespace from `firebase-admin/firestore` to auto-install the read
|
|
158
|
+
* trap (recommended — this is what makes every read count with no per-call work).
|
|
159
|
+
* Omit it if you'd rather call `installFirestoreMeter()` yourself, or you only
|
|
160
|
+
* use the manual `recordReads()` recorders.
|
|
161
|
+
*/
|
|
162
|
+
firestore?: FirestoreClasses;
|
|
163
|
+
/** Override the report endpoint (defaults to Crossdeck's ingest). */
|
|
164
|
+
endpoint?: string;
|
|
165
|
+
/** How often to flush coalesced counts (ms). Default 60_000. */
|
|
166
|
+
flushIntervalMs?: number;
|
|
167
|
+
/** Bring your own sink (self-host the rollups). Defaults to reporting up to Crossdeck. */
|
|
168
|
+
sink?: Sink;
|
|
169
|
+
/** Notified when a flush fails, so a dropped window is never silent. Best-effort. */
|
|
170
|
+
onError?: MeterConfig["onError"];
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Configure Buckets once, at process start. Points the meter at a sink (Crossdeck's
|
|
174
|
+
* ingest by default) and — if you pass `firestore` — installs the universal read
|
|
175
|
+
* trap so every read counts automatically.
|
|
176
|
+
*/
|
|
177
|
+
declare function init(options: InitOptions): void;
|
|
178
|
+
|
|
179
|
+
export { type BucketsReport, type CostHint, type CostTag, type FirestoreClasses, type InitOptions, type MeterConfig, type OpCounts, type OpType, ReportSink, type ReportSinkConfig, type Sink, bucket, currentCostTag, enterCostTag, flush, init, init as initBuckets, installFirestoreMeter, recordDeletes, recordReads, recordWrites, refineCostTag, runWithCostTag };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,145 @@
|
|
|
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
|
+
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
|
+
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
|
+
interface Sink {
|
|
32
|
+
flush(report: BucketsReport): Promise<void>;
|
|
33
|
+
}
|
|
34
|
+
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
|
+
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
|
+
|
|
57
|
+
type OpType = "read" | "write" | "delete";
|
|
58
|
+
/** Optional read-site hint — the collection touched, derived at the trap from the
|
|
59
|
+
* path. Lets an UNtagged read cascade to `col:<collection>` instead of vanishing. */
|
|
60
|
+
interface CostHint {
|
|
61
|
+
collection?: string;
|
|
62
|
+
projectId?: string;
|
|
63
|
+
}
|
|
64
|
+
interface MeterConfig {
|
|
65
|
+
sink: Sink;
|
|
66
|
+
flushIntervalMs?: number;
|
|
67
|
+
onError?: (e: unknown) => void;
|
|
68
|
+
}
|
|
69
|
+
/** Firestore bills a minimum of one read even for an empty result, so 0 counts as 1. */
|
|
70
|
+
declare function recordReads(n: number, hint?: CostHint): void;
|
|
71
|
+
declare function recordWrites(n?: number): void;
|
|
72
|
+
declare function recordDeletes(n?: number): void;
|
|
73
|
+
/**
|
|
74
|
+
* Coalesce the buffer into one report per UTC day and hand each to the Sink.
|
|
75
|
+
* Snapshots + clears up front so concurrent records land in the next window.
|
|
76
|
+
* Never throws; a sink failure drops that window (surfaced via `onError`).
|
|
77
|
+
*/
|
|
78
|
+
declare function flush(): Promise<void>;
|
|
79
|
+
|
|
80
|
+
type AnyFn = (...args: any[]) => any;
|
|
81
|
+
/**
|
|
82
|
+
* The firebase-admin Firestore classes to patch. Pass the module namespace from
|
|
83
|
+
* `firebase-admin/firestore` — only the prototypes present are patched.
|
|
84
|
+
*/
|
|
85
|
+
interface FirestoreClasses {
|
|
86
|
+
Query?: {
|
|
87
|
+
prototype: {
|
|
88
|
+
get?: AnyFn;
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
DocumentReference?: {
|
|
92
|
+
prototype: {
|
|
93
|
+
get?: AnyFn;
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
Transaction?: {
|
|
97
|
+
prototype: {
|
|
98
|
+
get?: AnyFn;
|
|
99
|
+
getAll?: AnyFn;
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
Firestore?: {
|
|
103
|
+
prototype: {
|
|
104
|
+
getAll?: AnyFn;
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Install the universal read meter on the firebase-admin Firestore classes. Call
|
|
110
|
+
* ONCE at process start, before any reads. Pass the namespace from
|
|
111
|
+
* `firebase-admin/firestore` so the exact prototypes the SDK uses are patched:
|
|
112
|
+
*
|
|
113
|
+
* import * as Firestore from "firebase-admin/firestore";
|
|
114
|
+
* installFirestoreMeter(Firestore);
|
|
115
|
+
*/
|
|
116
|
+
declare function installFirestoreMeter(classes: FirestoreClasses): void;
|
|
117
|
+
|
|
118
|
+
interface CostTag {
|
|
119
|
+
/** Optional coarse grouping (a caller-defined surface name). */
|
|
120
|
+
feature?: string;
|
|
121
|
+
/** The bucket name — what `bucket()` sets. Drives the report's `byLabel`. */
|
|
122
|
+
label?: string;
|
|
123
|
+
}
|
|
124
|
+
/** Run `fn` with `tag` bound for its entire async subtree. */
|
|
125
|
+
declare function runWithCostTag<T>(tag: CostTag, fn: () => T): T;
|
|
126
|
+
/** Bind a tag for the remainder of the current async context (no closure to wrap). */
|
|
127
|
+
declare function enterCostTag(tag: CostTag): void;
|
|
128
|
+
/** Refine the live tag in place (e.g. stamp a feature after the boundary). */
|
|
129
|
+
declare function refineCostTag(patch: Partial<CostTag>): void;
|
|
130
|
+
/** The current tag, or a safe empty default outside any bound context. */
|
|
131
|
+
declare function currentCostTag(): CostTag;
|
|
132
|
+
/**
|
|
133
|
+
* `bucket(name, fn)` — the headline verb, the `track()` of cost. Run `fn` with
|
|
134
|
+
* every operation inside it attributed to the bucket `name`; the attribution
|
|
135
|
+
* rides the async subtree automatically. The one verb most developers ever touch:
|
|
136
|
+
*
|
|
137
|
+
* await bucket("nightly-export", async () => {
|
|
138
|
+
* const rows = await db.collection("events").where(...).get(); // → "nightly-export"
|
|
139
|
+
* });
|
|
140
|
+
*/
|
|
141
|
+
declare function bucket<T>(name: string, fn: () => T): T;
|
|
142
|
+
|
|
1
143
|
/**
|
|
2
144
|
* @cross-deck/buckets — know exactly what every database read costs you, and who
|
|
3
145
|
* caused it. A tiny, never-throws collector for Firestore.
|
|
@@ -7,10 +149,8 @@
|
|
|
7
149
|
* 2. bucket(name, fn) — name the read paths that matter
|
|
8
150
|
* 3. (the dashboard shows the rest — and names the ones you haven't yet)
|
|
9
151
|
*/
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
import { type FirestoreClasses } from "./adapters/firestore.js";
|
|
13
|
-
export interface InitOptions {
|
|
152
|
+
|
|
153
|
+
interface InitOptions {
|
|
14
154
|
/** The project's `cd_sk_` SECRET key. Server-to-server only — never a browser key. */
|
|
15
155
|
apiKey: string;
|
|
16
156
|
/**
|
|
@@ -34,11 +174,6 @@ export interface InitOptions {
|
|
|
34
174
|
* ingest by default) and — if you pass `firestore` — installs the universal read
|
|
35
175
|
* trap so every read counts automatically.
|
|
36
176
|
*/
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
177
|
+
declare function init(options: InitOptions): void;
|
|
178
|
+
|
|
179
|
+
export { type BucketsReport, type CostHint, type CostTag, type FirestoreClasses, type InitOptions, type MeterConfig, type OpCounts, type OpType, ReportSink, type ReportSinkConfig, type Sink, bucket, currentCostTag, enterCostTag, flush, init, init as initBuckets, installFirestoreMeter, recordDeletes, recordReads, recordWrites, refineCostTag, runWithCostTag };
|
package/dist/index.js
CHANGED
|
@@ -1,34 +1,250 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var async_hooks = require('async_hooks');
|
|
4
|
+
|
|
5
|
+
// src/cost-context.ts
|
|
6
|
+
var DEFAULT_TAG = {};
|
|
7
|
+
var store = new async_hooks.AsyncLocalStorage();
|
|
8
|
+
function runWithCostTag(tag, fn) {
|
|
9
|
+
return store.run({ ...tag }, fn);
|
|
10
|
+
}
|
|
11
|
+
function enterCostTag(tag) {
|
|
12
|
+
store.enterWith({ ...tag });
|
|
13
|
+
}
|
|
14
|
+
function refineCostTag(patch) {
|
|
15
|
+
const cur = store.getStore();
|
|
16
|
+
if (cur) Object.assign(cur, patch);
|
|
17
|
+
}
|
|
18
|
+
function currentCostTag() {
|
|
19
|
+
return store.getStore() ?? DEFAULT_TAG;
|
|
20
|
+
}
|
|
21
|
+
function bucket(name, fn) {
|
|
22
|
+
return runWithCostTag({ ...currentCostTag(), label: name }, fn);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/cost-meter.ts
|
|
26
|
+
var SEP = "";
|
|
27
|
+
var labelBuffer = /* @__PURE__ */ new Map();
|
|
28
|
+
var hourBuffer = /* @__PURE__ */ new Map();
|
|
29
|
+
var sink = null;
|
|
30
|
+
var flushIntervalMs = 6e4;
|
|
31
|
+
var onError = null;
|
|
32
|
+
var timer = null;
|
|
33
|
+
var flushing = false;
|
|
34
|
+
var MAX_BUFFER_KEYS = 5e3;
|
|
35
|
+
function configureMeter(config) {
|
|
36
|
+
sink = config.sink;
|
|
37
|
+
if (config.flushIntervalMs && config.flushIntervalMs > 0) flushIntervalMs = config.flushIntervalMs;
|
|
38
|
+
onError = config.onError ?? null;
|
|
39
|
+
}
|
|
40
|
+
var utcDate = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
41
|
+
var utcHour = () => (/* @__PURE__ */ new Date()).toISOString().slice(11, 13);
|
|
42
|
+
function ensureFlushLoop() {
|
|
43
|
+
if (timer) return;
|
|
44
|
+
timer = setInterval(() => void flush(), flushIntervalMs);
|
|
45
|
+
timer.unref?.();
|
|
46
|
+
process.once?.("SIGTERM", () => void flush());
|
|
47
|
+
process.once?.("beforeExit", () => void flush());
|
|
48
|
+
}
|
|
49
|
+
function recordFirestore(op, n, hint) {
|
|
50
|
+
try {
|
|
51
|
+
if (!Number.isFinite(n) || n <= 0) return;
|
|
52
|
+
const t = currentCostTag();
|
|
53
|
+
const date = utcDate();
|
|
54
|
+
const label = t.label || (hint?.collection ? `col:${hint.collection}` : "uncategorized");
|
|
55
|
+
const lk = date + SEP + op + SEP + label;
|
|
56
|
+
labelBuffer.set(lk, (labelBuffer.get(lk) ?? 0) + n);
|
|
57
|
+
const hk = date + SEP + op + SEP + utcHour();
|
|
58
|
+
hourBuffer.set(hk, (hourBuffer.get(hk) ?? 0) + n);
|
|
59
|
+
ensureFlushLoop();
|
|
60
|
+
if (labelBuffer.size + hourBuffer.size > MAX_BUFFER_KEYS) void flush();
|
|
61
|
+
} catch {
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function recordReads(n, hint) {
|
|
65
|
+
recordFirestore("read", Math.max(n, 1), hint);
|
|
66
|
+
}
|
|
67
|
+
function recordWrites(n = 1) {
|
|
68
|
+
recordFirestore("write", n);
|
|
69
|
+
}
|
|
70
|
+
function recordDeletes(n = 1) {
|
|
71
|
+
recordFirestore("delete", n);
|
|
72
|
+
}
|
|
73
|
+
function add(target, key, op, n) {
|
|
74
|
+
const bag = target[key] ??= {};
|
|
75
|
+
bag[op] = (bag[op] ?? 0) + n;
|
|
76
|
+
}
|
|
77
|
+
async function flush() {
|
|
78
|
+
if (flushing) return;
|
|
79
|
+
if (!sink) {
|
|
80
|
+
labelBuffer.clear();
|
|
81
|
+
hourBuffer.clear();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (labelBuffer.size === 0 && hourBuffer.size === 0) return;
|
|
85
|
+
flushing = true;
|
|
86
|
+
const labels = new Map(labelBuffer);
|
|
87
|
+
const hours = new Map(hourBuffer);
|
|
88
|
+
labelBuffer.clear();
|
|
89
|
+
hourBuffer.clear();
|
|
90
|
+
try {
|
|
91
|
+
const byDate = /* @__PURE__ */ new Map();
|
|
92
|
+
const reportFor = (date) => {
|
|
93
|
+
let r = byDate.get(date);
|
|
94
|
+
if (!r) {
|
|
95
|
+
r = { date, byLabel: {}, byHour: {} };
|
|
96
|
+
byDate.set(date, r);
|
|
97
|
+
}
|
|
98
|
+
return r;
|
|
99
|
+
};
|
|
100
|
+
for (const [k, n] of labels) {
|
|
101
|
+
const [date, op, label] = k.split(SEP);
|
|
102
|
+
add(reportFor(date).byLabel, label, op, n);
|
|
103
|
+
}
|
|
104
|
+
for (const [k, n] of hours) {
|
|
105
|
+
const [date, op, hour] = k.split(SEP);
|
|
106
|
+
add(reportFor(date).byHour, hour, op, n);
|
|
107
|
+
}
|
|
108
|
+
for (const report of byDate.values()) {
|
|
109
|
+
await sink.flush(report);
|
|
110
|
+
}
|
|
111
|
+
} catch (e) {
|
|
112
|
+
onError?.(e);
|
|
113
|
+
} finally {
|
|
114
|
+
flushing = false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// src/sink.ts
|
|
119
|
+
var DEFAULT_ENDPOINT = "https://api.cross-deck.com/v1/buckets/report";
|
|
120
|
+
var ReportSink = class {
|
|
121
|
+
endpoint;
|
|
122
|
+
apiKey;
|
|
123
|
+
timeoutMs;
|
|
124
|
+
constructor(config) {
|
|
125
|
+
this.endpoint = config.endpoint ?? DEFAULT_ENDPOINT;
|
|
126
|
+
this.apiKey = config.apiKey;
|
|
127
|
+
this.timeoutMs = config.timeoutMs ?? 5e3;
|
|
128
|
+
}
|
|
129
|
+
async flush(report) {
|
|
130
|
+
const res = await fetch(this.endpoint, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
133
|
+
headers: {
|
|
134
|
+
"content-type": "application/json",
|
|
135
|
+
authorization: `Bearer ${this.apiKey}`
|
|
136
|
+
},
|
|
137
|
+
body: JSON.stringify(report)
|
|
138
|
+
});
|
|
139
|
+
if (res.status !== 202) {
|
|
140
|
+
throw new Error(`Buckets report rejected: HTTP ${res.status}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// src/adapters/firestore.ts
|
|
146
|
+
var installed = false;
|
|
147
|
+
function projectFromPath(path) {
|
|
148
|
+
const parts = path.split("/");
|
|
149
|
+
const i = parts.indexOf("projects");
|
|
150
|
+
return i >= 0 && parts[i + 1] ? parts[i + 1] : void 0;
|
|
151
|
+
}
|
|
152
|
+
function hintFrom(target) {
|
|
153
|
+
try {
|
|
154
|
+
const p = typeof target?.path === "string" ? target.path : "";
|
|
155
|
+
if (p) {
|
|
156
|
+
const parts = p.split("/").filter(Boolean);
|
|
157
|
+
const collection = parts.length % 2 === 0 ? parts[parts.length - 2] : parts[parts.length - 1];
|
|
158
|
+
return { collection, projectId: projectFromPath(p) };
|
|
159
|
+
}
|
|
160
|
+
const qo = target?._queryOptions;
|
|
161
|
+
if (qo) {
|
|
162
|
+
const collection = typeof qo.collectionId === "string" ? qo.collectionId : void 0;
|
|
163
|
+
const parent = typeof qo.parentPath?.relativeName === "string" ? qo.parentPath.relativeName : typeof qo.parentPath?.toString === "function" ? String(qo.parentPath.toString()) : "";
|
|
164
|
+
return { collection, projectId: parent ? projectFromPath(parent) : void 0 };
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
}
|
|
168
|
+
return void 0;
|
|
169
|
+
}
|
|
170
|
+
function meterSnap(snap, hint) {
|
|
171
|
+
try {
|
|
172
|
+
const size = snap?.size;
|
|
173
|
+
recordReads(typeof size === "number" ? size : 1, hint);
|
|
174
|
+
} catch {
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function meterCount(n, hint) {
|
|
178
|
+
try {
|
|
179
|
+
recordReads(n, hint);
|
|
180
|
+
} catch {
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
function installFirestoreMeter(classes) {
|
|
184
|
+
if (installed) return;
|
|
185
|
+
installed = true;
|
|
186
|
+
const { Query, DocumentReference, Transaction, Firestore } = classes;
|
|
187
|
+
const qGet = Query?.prototype?.get;
|
|
188
|
+
if (qGet) {
|
|
189
|
+
Query.prototype.get = async function(...args) {
|
|
190
|
+
const snap = await qGet.apply(this, args);
|
|
191
|
+
meterSnap(snap, hintFrom(this));
|
|
192
|
+
return snap;
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
const dGet = DocumentReference?.prototype?.get;
|
|
196
|
+
if (dGet) {
|
|
197
|
+
DocumentReference.prototype.get = async function(...args) {
|
|
198
|
+
const snap = await dGet.apply(this, args);
|
|
199
|
+
meterCount(1, hintFrom(this));
|
|
200
|
+
return snap;
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
const tGet = Transaction?.prototype?.get;
|
|
204
|
+
if (tGet) {
|
|
205
|
+
Transaction.prototype.get = async function(...args) {
|
|
206
|
+
const res = await tGet.apply(this, args);
|
|
207
|
+
meterSnap(res, hintFrom(args[0]));
|
|
208
|
+
return res;
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const tGetAll = Transaction?.prototype?.getAll;
|
|
212
|
+
if (tGetAll) {
|
|
213
|
+
Transaction.prototype.getAll = async function(...args) {
|
|
214
|
+
const res = await tGetAll.apply(this, args);
|
|
215
|
+
meterCount(Array.isArray(res) ? res.length : args.length || 1, hintFrom(args[0]));
|
|
216
|
+
return res;
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
const fGetAll = Firestore?.prototype?.getAll;
|
|
220
|
+
if (fGetAll) {
|
|
221
|
+
Firestore.prototype.getAll = async function(...args) {
|
|
222
|
+
const res = await fGetAll.apply(this, args);
|
|
223
|
+
meterCount(Array.isArray(res) ? res.length : args.length || 1, hintFrom(args[0]));
|
|
224
|
+
return res;
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// src/index.ts
|
|
230
|
+
function init(options) {
|
|
231
|
+
const sink2 = options.sink ?? new ReportSink({ apiKey: options.apiKey, endpoint: options.endpoint });
|
|
232
|
+
configureMeter({ sink: sink2, flushIntervalMs: options.flushIntervalMs, onError: options.onError });
|
|
233
|
+
if (options.firestore) installFirestoreMeter(options.firestore);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
exports.ReportSink = ReportSink;
|
|
237
|
+
exports.bucket = bucket;
|
|
238
|
+
exports.currentCostTag = currentCostTag;
|
|
239
|
+
exports.enterCostTag = enterCostTag;
|
|
240
|
+
exports.flush = flush;
|
|
241
|
+
exports.init = init;
|
|
242
|
+
exports.initBuckets = init;
|
|
243
|
+
exports.installFirestoreMeter = installFirestoreMeter;
|
|
244
|
+
exports.recordDeletes = recordDeletes;
|
|
245
|
+
exports.recordReads = recordReads;
|
|
246
|
+
exports.recordWrites = recordWrites;
|
|
247
|
+
exports.refineCostTag = refineCostTag;
|
|
248
|
+
exports.runWithCostTag = runWithCostTag;
|
|
249
|
+
//# sourceMappingURL=index.js.map
|
|
34
250
|
//# sourceMappingURL=index.js.map
|