@trazum/core 1.41.0 → 1.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/dist/config-schema.d.ts +26 -2
- package/dist/config-schema.d.ts.map +1 -1
- package/dist/config-schema.js +31 -1
- package/dist/config-schema.js.map +1 -1
- package/dist/history.d.ts +7 -2
- package/dist/history.d.ts.map +1 -1
- package/dist/history.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/store.d.ts +134 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +177 -0
- package/dist/store.js.map +1 -0
- package/dist/watch.d.ts +130 -0
- package/dist/watch.d.ts.map +1 -0
- package/dist/watch.js +104 -0
- package/dist/watch.js.map +1 -0
- package/package.json +1 -1
- package/src/config-schema.ts +55 -1
- package/src/history.ts +7 -2
- package/src/index.ts +20 -0
- package/src/store.ts +281 -0
- package/src/watch.ts +203 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A year of measured spend on disk, and not one prompt inside it.
|
|
3
|
+
*
|
|
4
|
+
* A connector that re-downloads a month every time it runs is a connector
|
|
5
|
+
* nobody leaves on, and `history` needs stored reports — which until now meant
|
|
6
|
+
* a human curating a directory of `--json` files. The store is where pulled
|
|
7
|
+
* usage lands so neither is true any more.
|
|
8
|
+
*
|
|
9
|
+
* **Pure, and in the core.** This module decides what a record *is*, when two
|
|
10
|
+
* records are the same record, and what a set of them adds up to. The
|
|
11
|
+
* filesystem lives in the CLI, the same split every other browser-safe module
|
|
12
|
+
* here keeps.
|
|
13
|
+
*
|
|
14
|
+
* **Convergence, not accumulation.** Re-pulling an overlapping window must not
|
|
15
|
+
* double the bill. Two records covering the same window, from the same
|
|
16
|
+
* provider, for the same model and grouping, are the *same fact restated* —
|
|
17
|
+
* the later pull wins, because a window pulled again is at worst as complete
|
|
18
|
+
* as it was. That makes overlapping pulls idempotent, which is what lets a
|
|
19
|
+
* scheduled job run every hour over a rolling day without inventing money.
|
|
20
|
+
*
|
|
21
|
+
* **Deduplication that cannot lie.** Two records the store cannot tell apart —
|
|
22
|
+
* a source that served no window, or no model — are kept as *two* and reported
|
|
23
|
+
* as possibly-double. Merging them on a guess makes a bill quietly smaller,
|
|
24
|
+
* and quietly smaller is the flattering direction this repository refuses
|
|
25
|
+
* everywhere it can occur.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { UsageBucket } from './connector.js';
|
|
29
|
+
|
|
30
|
+
/** Bump when the meaning of a field changes. Readers keep what they cannot read. */
|
|
31
|
+
export const STORE_SCHEMA_VERSION = 1;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One stored measurement.
|
|
35
|
+
*
|
|
36
|
+
* Short keys because a year of these is a file somebody has to keep, and the
|
|
37
|
+
* shape is documented rather than inferred from its own verbosity.
|
|
38
|
+
*/
|
|
39
|
+
export interface StoreRecord {
|
|
40
|
+
/** Schema version of this line, so an old reader keeps what it cannot parse. */
|
|
41
|
+
v: number;
|
|
42
|
+
provider: string;
|
|
43
|
+
fromMs: number;
|
|
44
|
+
toMs: number;
|
|
45
|
+
model: string;
|
|
46
|
+
/** null when the source serves no request count. Never zero for absent. */
|
|
47
|
+
calls: number | null;
|
|
48
|
+
input: number;
|
|
49
|
+
cacheRead: number;
|
|
50
|
+
write5m: number;
|
|
51
|
+
write1h: number;
|
|
52
|
+
/** False when the source reported writes without saying which TTL. */
|
|
53
|
+
ttlKnown: boolean;
|
|
54
|
+
output: number;
|
|
55
|
+
/**
|
|
56
|
+
* The account's own opaque identifiers — workspace, key, tier — as the
|
|
57
|
+
* provider served them.
|
|
58
|
+
*
|
|
59
|
+
* These are identifiers, not secrets: they name a workspace, not a way into
|
|
60
|
+
* it, and per-owner attribution needs them. Prompt text, completion text and
|
|
61
|
+
* credentials are never here, and the documentation says exactly what a
|
|
62
|
+
* store holds rather than leaving somebody to guess about their own file.
|
|
63
|
+
*/
|
|
64
|
+
group: Record<string, string>;
|
|
65
|
+
/** When this record was pulled, which is how the later restatement wins. */
|
|
66
|
+
pulledAtMs: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** What makes two records the same record. See the module note. */
|
|
70
|
+
export function identityOf(record: StoreRecord): string {
|
|
71
|
+
return [
|
|
72
|
+
record.provider,
|
|
73
|
+
record.fromMs,
|
|
74
|
+
record.toMs,
|
|
75
|
+
record.model,
|
|
76
|
+
JSON.stringify(record.group),
|
|
77
|
+
].join('\n');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A record whose identity is not trustworthy enough to converge on.
|
|
82
|
+
*
|
|
83
|
+
* A window of zero length, or a record with no model, cannot be told apart
|
|
84
|
+
* from another like it. Those are kept whole and counted separately.
|
|
85
|
+
*/
|
|
86
|
+
function identifiable(record: StoreRecord): boolean {
|
|
87
|
+
return record.model !== '' && record.toMs > record.fromMs;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ResolvedStore {
|
|
91
|
+
/** One record per identity, newest pull winning. */
|
|
92
|
+
records: StoreRecord[];
|
|
93
|
+
/**
|
|
94
|
+
* Records the store could not tell apart, kept in full rather than merged.
|
|
95
|
+
*
|
|
96
|
+
* Reported so a total built on them can be read for what it is: possibly
|
|
97
|
+
* counting the same spend twice, and saying so beats a smaller number
|
|
98
|
+
* nobody can check.
|
|
99
|
+
*/
|
|
100
|
+
possiblyDouble: StoreRecord[];
|
|
101
|
+
/** Lines from a schema version this binary does not know, kept and counted. */
|
|
102
|
+
unknownVersion: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Collapses an append-only log into the current truth.
|
|
107
|
+
*
|
|
108
|
+
* Append-only on disk and last-wins at read time, rather than rewriting a file
|
|
109
|
+
* in place: a crash during a rewrite can lose a year, and a crash during an
|
|
110
|
+
* append loses the tail of one line.
|
|
111
|
+
*/
|
|
112
|
+
export function resolveStore(records: readonly StoreRecord[]): ResolvedStore {
|
|
113
|
+
const byIdentity = new Map<string, StoreRecord>();
|
|
114
|
+
const possiblyDouble: StoreRecord[] = [];
|
|
115
|
+
let unknownVersion = 0;
|
|
116
|
+
|
|
117
|
+
for (const record of records) {
|
|
118
|
+
if (record.v > STORE_SCHEMA_VERSION) {
|
|
119
|
+
unknownVersion += 1;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (!identifiable(record)) {
|
|
123
|
+
possiblyDouble.push(record);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const key = identityOf(record);
|
|
127
|
+
const seen = byIdentity.get(key);
|
|
128
|
+
if (seen === undefined || record.pulledAtMs >= seen.pulledAtMs) {
|
|
129
|
+
byIdentity.set(key, record);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
records: [...byIdentity.values()].sort(
|
|
135
|
+
(a, b) => a.fromMs - b.fromMs || a.model.localeCompare(b.model),
|
|
136
|
+
),
|
|
137
|
+
possiblyDouble,
|
|
138
|
+
unknownVersion,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Turns a connector's buckets into records ready to append. */
|
|
143
|
+
export function recordsFromBuckets(
|
|
144
|
+
provider: string,
|
|
145
|
+
buckets: readonly UsageBucket[],
|
|
146
|
+
pulledAtMs: number,
|
|
147
|
+
): StoreRecord[] {
|
|
148
|
+
return buckets.map((bucket) => ({
|
|
149
|
+
v: STORE_SCHEMA_VERSION,
|
|
150
|
+
provider,
|
|
151
|
+
fromMs: bucket.fromMs,
|
|
152
|
+
toMs: bucket.toMs,
|
|
153
|
+
model: bucket.model,
|
|
154
|
+
calls: bucket.calls,
|
|
155
|
+
input: bucket.inputTokens,
|
|
156
|
+
cacheRead: bucket.cacheReadTokens,
|
|
157
|
+
write5m: bucket.cacheWrite5mTokens,
|
|
158
|
+
write1h: bucket.cacheWrite1hTokens,
|
|
159
|
+
ttlKnown: bucket.writeTtlKnown,
|
|
160
|
+
output: bucket.outputTokens,
|
|
161
|
+
group: bucket.group,
|
|
162
|
+
pulledAtMs,
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The reverse, so a stored month prices exactly as a fresh pull does. */
|
|
167
|
+
export function bucketsFromRecords(records: readonly StoreRecord[]): UsageBucket[] {
|
|
168
|
+
return records.map((record) => ({
|
|
169
|
+
fromMs: record.fromMs,
|
|
170
|
+
toMs: record.toMs,
|
|
171
|
+
model: record.model,
|
|
172
|
+
calls: record.calls,
|
|
173
|
+
inputTokens: record.input,
|
|
174
|
+
cacheReadTokens: record.cacheRead,
|
|
175
|
+
cacheWrite5mTokens: record.write5m,
|
|
176
|
+
cacheWrite1hTokens: record.write1h,
|
|
177
|
+
writeTtlKnown: record.ttlKnown,
|
|
178
|
+
outputTokens: record.output,
|
|
179
|
+
group: record.group,
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --------------------------------------------------------------------------
|
|
184
|
+
// What is in the store
|
|
185
|
+
// --------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
export interface StoreInventory {
|
|
188
|
+
schemaVersion: 1;
|
|
189
|
+
/** Per provider, oldest first by the span it covers. */
|
|
190
|
+
providers: {
|
|
191
|
+
provider: string;
|
|
192
|
+
records: number;
|
|
193
|
+
span: { fromMs: number; toMs: number } | null;
|
|
194
|
+
/** null when no provider in the set serves request counts. */
|
|
195
|
+
calls: number | null;
|
|
196
|
+
models: string[];
|
|
197
|
+
}[];
|
|
198
|
+
totalRecords: number;
|
|
199
|
+
span: { fromMs: number; toMs: number } | null;
|
|
200
|
+
possiblyDouble: number;
|
|
201
|
+
unknownVersion: number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function storeInventory(resolved: ResolvedStore): StoreInventory {
|
|
205
|
+
const byProvider = new Map<string, StoreRecord[]>();
|
|
206
|
+
for (const record of resolved.records) {
|
|
207
|
+
const list = byProvider.get(record.provider) ?? [];
|
|
208
|
+
list.push(record);
|
|
209
|
+
byProvider.set(record.provider, list);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const providers = [...byProvider.entries()]
|
|
213
|
+
.map(([provider, records]) => {
|
|
214
|
+
const anyUnknown = records.some((r) => r.calls === null);
|
|
215
|
+
return {
|
|
216
|
+
provider,
|
|
217
|
+
records: records.length,
|
|
218
|
+
span: {
|
|
219
|
+
fromMs: Math.min(...records.map((r) => r.fromMs)),
|
|
220
|
+
toMs: Math.max(...records.map((r) => r.toMs)),
|
|
221
|
+
},
|
|
222
|
+
calls: anyUnknown ? null : records.reduce((sum, r) => sum + (r.calls ?? 0), 0),
|
|
223
|
+
models: [...new Set(records.map((r) => r.model))].sort(),
|
|
224
|
+
};
|
|
225
|
+
})
|
|
226
|
+
.sort((a, b) => a.provider.localeCompare(b.provider));
|
|
227
|
+
|
|
228
|
+
const all = resolved.records;
|
|
229
|
+
return {
|
|
230
|
+
schemaVersion: 1,
|
|
231
|
+
providers,
|
|
232
|
+
totalRecords: all.length,
|
|
233
|
+
span:
|
|
234
|
+
all.length === 0
|
|
235
|
+
? null
|
|
236
|
+
: {
|
|
237
|
+
fromMs: Math.min(...all.map((r) => r.fromMs)),
|
|
238
|
+
toMs: Math.max(...all.map((r) => r.toMs)),
|
|
239
|
+
},
|
|
240
|
+
possiblyDouble: resolved.possiblyDouble.length,
|
|
241
|
+
unknownVersion: resolved.unknownVersion,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// --------------------------------------------------------------------------
|
|
246
|
+
// Retention
|
|
247
|
+
// --------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
export interface PruneResult {
|
|
250
|
+
kept: StoreRecord[];
|
|
251
|
+
dropped: StoreRecord[];
|
|
252
|
+
/** The span the dropped records covered — what a reader loses by pruning. */
|
|
253
|
+
droppedSpan: { fromMs: number; toMs: number } | null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Drops records whose window ended before the cutoff.
|
|
258
|
+
*
|
|
259
|
+
* Judged on `toMs`: a bucket that *ends* inside the retained period is
|
|
260
|
+
* retained whole, because half a bucket is a measurement of nothing. What goes
|
|
261
|
+
* is returned rather than counted, so the caller can say what went — silence
|
|
262
|
+
* about deleted measurements is the one thing a store must not do.
|
|
263
|
+
*/
|
|
264
|
+
export function pruneRecords(records: readonly StoreRecord[], cutoffMs: number): PruneResult {
|
|
265
|
+
const kept: StoreRecord[] = [];
|
|
266
|
+
const dropped: StoreRecord[] = [];
|
|
267
|
+
for (const record of records) {
|
|
268
|
+
(record.toMs < cutoffMs ? dropped : kept).push(record);
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
kept,
|
|
272
|
+
dropped,
|
|
273
|
+
droppedSpan:
|
|
274
|
+
dropped.length === 0
|
|
275
|
+
? null
|
|
276
|
+
: {
|
|
277
|
+
fromMs: Math.min(...dropped.map((r) => r.fromMs)),
|
|
278
|
+
toMs: Math.max(...dropped.map((r) => r.toMs)),
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
package/src/watch.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The afternoon the loop burned a quarter of the month, said that afternoon.
|
|
3
|
+
*
|
|
4
|
+
* Every gate in this product fires when a human runs a command. The failures
|
|
5
|
+
* worth catching — a retry loop, a prompt that grew, a model swapped in a
|
|
6
|
+
* deploy — happen at 3pm on a Tuesday, and a report that arrives three weeks
|
|
7
|
+
* later is an obituary.
|
|
8
|
+
*
|
|
9
|
+
* This module decides **what has crossed**, given what is measured and what
|
|
10
|
+
* the operator asked to be told about. The pulling, the storing, the sleeping
|
|
11
|
+
* and the sending live in the CLI; everything here is arithmetic over figures
|
|
12
|
+
* somebody already has, which is what makes an alerting rule testable without
|
|
13
|
+
* waiting for 3pm.
|
|
14
|
+
*
|
|
15
|
+
* **An alert fires on a measured crossing, never on a projection.** "You will
|
|
16
|
+
* exceed" is a forecast, and this product has refused those at every window
|
|
17
|
+
* length since 1.27. "You have spent $412 of a $400 budget, measured over
|
|
18
|
+
* these calls" is a fact, and the difference is the only reason an alert at
|
|
19
|
+
* 3am can be trusted.
|
|
20
|
+
*
|
|
21
|
+
* **A window too short to mean anything does not fire.** A day gate needs a
|
|
22
|
+
* whole day of measurement before it can fail: the first ten minutes of a day
|
|
23
|
+
* are not a day, and a watcher that cries at every dawn gets muted — which is
|
|
24
|
+
* how alerting tools actually fail.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { BucketedReport } from './connector.js';
|
|
28
|
+
|
|
29
|
+
export type WatchGate = 'maxUsd' | 'maxDayUsd' | 'maxCacheLossUsd';
|
|
30
|
+
|
|
31
|
+
/** Why a gate could not be judged rather than passed. */
|
|
32
|
+
export type NotJudgeable =
|
|
33
|
+
/** Not enough of the period is measured for the threshold to mean anything. */
|
|
34
|
+
| 'window-too-short'
|
|
35
|
+
/** The source cannot serve the dimension this gate is written against. */
|
|
36
|
+
| 'dimension-unavailable';
|
|
37
|
+
|
|
38
|
+
export interface WatchCrossing {
|
|
39
|
+
gate: WatchGate;
|
|
40
|
+
/** The measured figure that crossed, and the threshold it crossed. */
|
|
41
|
+
measuredUsd: number;
|
|
42
|
+
limitUsd: number;
|
|
43
|
+
/** Which slice of time the figure covers — a day gate names its day. */
|
|
44
|
+
window: { fromMs: number; toMs: number };
|
|
45
|
+
/** A day gate's UTC day, so the alert names the afternoon it means. */
|
|
46
|
+
day: string | null;
|
|
47
|
+
/**
|
|
48
|
+
* Everything a machine reader needs to know what kind of number this is.
|
|
49
|
+
*
|
|
50
|
+
* `measured` is the only value this module will ever emit: a projected
|
|
51
|
+
* crossing is not a crossing. The field exists anyway, because a consumer
|
|
52
|
+
* that cannot see the provenance will treat whatever arrives as fact — and
|
|
53
|
+
* a later version of this file must not be able to smuggle an estimate past
|
|
54
|
+
* a reader by leaving the question unasked.
|
|
55
|
+
*/
|
|
56
|
+
provenance: 'measured';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface WatchAbstention {
|
|
60
|
+
gate: WatchGate;
|
|
61
|
+
reason: NotJudgeable;
|
|
62
|
+
/** What is missing, as a figure the operator can act on. */
|
|
63
|
+
detail: { coveredMs: number; neededMs: number } | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface WatchResult {
|
|
67
|
+
crossings: WatchCrossing[];
|
|
68
|
+
/**
|
|
69
|
+
* Still over the limit, and already reported on an earlier cycle.
|
|
70
|
+
*
|
|
71
|
+
* These are the reason a quiet cycle is not the same as a clean one. A
|
|
72
|
+
* restart that reported "within every threshold" while the budget was still
|
|
73
|
+
* blown would be the flattering reading this product refuses everywhere:
|
|
74
|
+
* the alert was suppressed, the *crossing* was not, and only one of those
|
|
75
|
+
* is news.
|
|
76
|
+
*/
|
|
77
|
+
suppressed: WatchCrossing[];
|
|
78
|
+
/**
|
|
79
|
+
* Gates that could not be judged, which is neither a pass nor a failure.
|
|
80
|
+
*
|
|
81
|
+
* Reported rather than swallowed: a gate silently skipped for a week reads
|
|
82
|
+
* exactly like a gate that has been passing for a week, and those are very
|
|
83
|
+
* different states to be in.
|
|
84
|
+
*/
|
|
85
|
+
abstentions: WatchAbstention[];
|
|
86
|
+
/**
|
|
87
|
+
* The stretch this cycle did not watch, when a watcher was down or is
|
|
88
|
+
* starting for the first time. A resumed watcher that says nothing implies
|
|
89
|
+
* coverage it did not have.
|
|
90
|
+
*/
|
|
91
|
+
gap: { fromMs: number; toMs: number } | null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface WatchThresholds {
|
|
95
|
+
maxUsd?: number;
|
|
96
|
+
maxDayUsd?: number;
|
|
97
|
+
maxCacheLossUsd?: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface WatchOptions {
|
|
101
|
+
/** Priced measurements for the period being watched. */
|
|
102
|
+
report: BucketedReport;
|
|
103
|
+
thresholds: WatchThresholds;
|
|
104
|
+
/** The cache verdict over the same report, when the caller computed one. */
|
|
105
|
+
cacheDeltaUsd?: number;
|
|
106
|
+
/** Now, so a partly-elapsed day can be told from a whole one. */
|
|
107
|
+
nowMs: number;
|
|
108
|
+
/** Where the previous cycle finished, for the coverage gap. */
|
|
109
|
+
lastCoveredToMs?: number;
|
|
110
|
+
/**
|
|
111
|
+
* Gates already fired, by gate and by day, so a restart is not amnesia.
|
|
112
|
+
*
|
|
113
|
+
* Keyed `gate` for whole-period gates and `gate\nYYYY-MM-DD` for a day, so
|
|
114
|
+
* a day that already alerted stays quiet while a *new* day crossing still
|
|
115
|
+
* speaks.
|
|
116
|
+
*/
|
|
117
|
+
alreadyFired?: ReadonlySet<string>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** A day gate cannot judge a day that has not finished being measured. */
|
|
121
|
+
export const DAY_MS = 86_400_000;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* How much of a period must be measured before a threshold over it means
|
|
125
|
+
* anything. Nine tenths rather than all of it: a usage API's last bucket is
|
|
126
|
+
* often minutes behind, and a gate that waits for perfection never fires.
|
|
127
|
+
*/
|
|
128
|
+
export const COVERAGE_FLOOR = 0.9;
|
|
129
|
+
|
|
130
|
+
export function firedKey(gate: WatchGate, day: string | null): string {
|
|
131
|
+
return day === null ? gate : `${gate}\n${day}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function evaluateWatch(options: WatchOptions): WatchResult {
|
|
135
|
+
const { report, thresholds, cacheDeltaUsd, nowMs, lastCoveredToMs, alreadyFired } = options;
|
|
136
|
+
const fired = alreadyFired ?? new Set<string>();
|
|
137
|
+
const crossings: WatchCrossing[] = [];
|
|
138
|
+
const suppressed: WatchCrossing[] = [];
|
|
139
|
+
const abstentions: WatchAbstention[] = [];
|
|
140
|
+
|
|
141
|
+
const span = report.span;
|
|
142
|
+
|
|
143
|
+
const push = (gate: WatchGate, measuredUsd: number, limitUsd: number, day: string | null, window: { fromMs: number; toMs: number }): void => {
|
|
144
|
+
if (measuredUsd <= limitUsd) return;
|
|
145
|
+
const crossing: WatchCrossing = { gate, measuredUsd, limitUsd, window, day, provenance: 'measured' };
|
|
146
|
+
// Already told: quiet, but still crossed. The two are kept apart because
|
|
147
|
+
// "we alerted about this" and "this is fine now" are different sentences.
|
|
148
|
+
(fired.has(firedKey(gate, day)) ? suppressed : crossings).push(crossing);
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
if (thresholds.maxUsd !== undefined) {
|
|
152
|
+
if (span === null) {
|
|
153
|
+
abstentions.push({ gate: 'maxUsd', reason: 'dimension-unavailable', detail: null });
|
|
154
|
+
} else {
|
|
155
|
+
push('maxUsd', report.total.totalUsd, thresholds.maxUsd, null, span);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (thresholds.maxCacheLossUsd !== undefined) {
|
|
160
|
+
if (cacheDeltaUsd === undefined || span === null) {
|
|
161
|
+
abstentions.push({ gate: 'maxCacheLossUsd', reason: 'dimension-unavailable', detail: null });
|
|
162
|
+
} else {
|
|
163
|
+
push('maxCacheLossUsd', cacheDeltaUsd, thresholds.maxCacheLossUsd, null, span);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (thresholds.maxDayUsd !== undefined) {
|
|
168
|
+
if (report.byDay.length === 0) {
|
|
169
|
+
abstentions.push({ gate: 'maxDayUsd', reason: 'dimension-unavailable', detail: null });
|
|
170
|
+
} else {
|
|
171
|
+
for (const entry of report.byDay) {
|
|
172
|
+
const dayStart = Date.parse(`${entry.day}T00:00:00Z`);
|
|
173
|
+
const dayEnd = dayStart + DAY_MS;
|
|
174
|
+
/**
|
|
175
|
+
* The day still running is measured only up to now, so a threshold
|
|
176
|
+
* over it is a threshold over a fraction of a day. Once it *has*
|
|
177
|
+
* crossed, the crossing is real whatever the hour — a day that is
|
|
178
|
+
* already over budget at noon does not become less over budget at
|
|
179
|
+
* midnight — so the abstention only applies while the figure is
|
|
180
|
+
* still under the limit.
|
|
181
|
+
*/
|
|
182
|
+
const covered = Math.min(nowMs, dayEnd) - dayStart;
|
|
183
|
+
const whole = covered >= DAY_MS * COVERAGE_FLOOR;
|
|
184
|
+
if (entry.usd > thresholds.maxDayUsd) {
|
|
185
|
+
push('maxDayUsd', entry.usd, thresholds.maxDayUsd, entry.day, { fromMs: dayStart, toMs: dayEnd });
|
|
186
|
+
} else if (!whole) {
|
|
187
|
+
abstentions.push({
|
|
188
|
+
gate: 'maxDayUsd',
|
|
189
|
+
reason: 'window-too-short',
|
|
190
|
+
detail: { coveredMs: Math.max(0, covered), neededMs: DAY_MS },
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const gap =
|
|
198
|
+
lastCoveredToMs !== undefined && span !== null && span.fromMs > lastCoveredToMs
|
|
199
|
+
? { fromMs: lastCoveredToMs, toMs: span.fromMs }
|
|
200
|
+
: null;
|
|
201
|
+
|
|
202
|
+
return { crossings, suppressed, abstentions, gap };
|
|
203
|
+
}
|