@bli-cockpit/telemetry-core 0.1.24 → 0.1.26
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/dist/collector-heartbeat.d.ts +75 -0
- package/dist/collector-heartbeat.js +106 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/upload-failure-class.d.ts +48 -0
- package/dist/upload-failure-class.js +79 -0
- package/package.json +1 -1
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The proof that a collector is alive, and what it is allowed to look at.
|
|
3
|
+
*
|
|
4
|
+
* BLI-3551. Until now the ONLY writer of
|
|
5
|
+
* `ambient_collector_devices.last_seen_at` was the ambient ingest path
|
|
6
|
+
* (`apps/dashboard/src/lib/ambient/ingest.ts`), which runs when there is an
|
|
7
|
+
* envelope to send. A machine whose operator works entirely outside the
|
|
8
|
+
* approved roots sends no envelope, so it goes quiet — and a quiet machine and
|
|
9
|
+
* a dead machine look exactly the same from the dashboard. One device ticked
|
|
10
|
+
* 377 times in 38 hours and the last-seen column did not move once.
|
|
11
|
+
*
|
|
12
|
+
* The heartbeat closes that. It is deliberately NOT an ambient envelope: the
|
|
13
|
+
* envelope requires a work context, a session reference and at least one event,
|
|
14
|
+
* and inventing all three per tick would create a work-session row every 15
|
|
15
|
+
* minutes for a machine that collected nothing. A separate door with its own
|
|
16
|
+
* tiny schema costs less and says only what it means.
|
|
17
|
+
*
|
|
18
|
+
* The approved roots ride along as LABELS and never as paths. A root is
|
|
19
|
+
* `{ basename, path_sha256 }`: the basename is the folder name a person would
|
|
20
|
+
* recognise (`BLI`, `repos`), the hash is the stable identity of the normalized
|
|
21
|
+
* absolute path, so two machines pointing at the same folder are comparable
|
|
22
|
+
* without either path leaving the device. This is the same rule the session
|
|
23
|
+
* rows already follow with `cwd_basename` / `cwd_hash`.
|
|
24
|
+
*/
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
export declare const COLLECTOR_HEARTBEAT_SCHEMA_VERSION = "collector-heartbeat.v1";
|
|
27
|
+
/** The most roots one machine may declare. Well above any real operator. */
|
|
28
|
+
export declare const COLLECTOR_HEARTBEAT_MAX_ROOTS = 40;
|
|
29
|
+
export declare const CollectorRootLabelSchema: z.ZodObject<{
|
|
30
|
+
basename: z.ZodString;
|
|
31
|
+
path_sha256: z.ZodString;
|
|
32
|
+
}, z.core.$strict>;
|
|
33
|
+
export type CollectorRootLabel = z.infer<typeof CollectorRootLabelSchema>;
|
|
34
|
+
/** What the last collection tick decided, in the collector's own vocabulary. */
|
|
35
|
+
export declare const CollectorHeartbeatSyncStatusSchema: z.ZodEnum<{
|
|
36
|
+
skipped: "skipped";
|
|
37
|
+
ok: "ok";
|
|
38
|
+
fail: "fail";
|
|
39
|
+
not_run: "not_run";
|
|
40
|
+
}>;
|
|
41
|
+
export declare const CollectorHeartbeatSchema: z.ZodObject<{
|
|
42
|
+
schema_version: z.ZodLiteral<"collector-heartbeat.v1">;
|
|
43
|
+
generated_at: z.ZodString;
|
|
44
|
+
collector_version: z.ZodString;
|
|
45
|
+
os_platform: z.ZodOptional<z.ZodString>;
|
|
46
|
+
roots: z.ZodArray<z.ZodObject<{
|
|
47
|
+
basename: z.ZodString;
|
|
48
|
+
path_sha256: z.ZodString;
|
|
49
|
+
}, z.core.$strict>>;
|
|
50
|
+
last_sync_status: z.ZodDefault<z.ZodEnum<{
|
|
51
|
+
skipped: "skipped";
|
|
52
|
+
ok: "ok";
|
|
53
|
+
fail: "fail";
|
|
54
|
+
not_run: "not_run";
|
|
55
|
+
}>>;
|
|
56
|
+
last_sync_reason: z.ZodOptional<z.ZodString>;
|
|
57
|
+
sessions_observed: z.ZodOptional<z.ZodNumber>;
|
|
58
|
+
sessions_outside_root: z.ZodOptional<z.ZodNumber>;
|
|
59
|
+
}, z.core.$strict>;
|
|
60
|
+
export type CollectorHeartbeat = z.infer<typeof CollectorHeartbeatSchema>;
|
|
61
|
+
/**
|
|
62
|
+
* What the device row's `metadata.roots` holds after a heartbeat lands.
|
|
63
|
+
*
|
|
64
|
+
* Stored as its own key rather than merged into the row's top level so an
|
|
65
|
+
* older reader of `metadata` is unaffected, and so "which folders is this
|
|
66
|
+
* machine allowed to see, and when did it last say so" is one lookup.
|
|
67
|
+
*/
|
|
68
|
+
export declare const CollectorDeviceRootsMetadataSchema: z.ZodObject<{
|
|
69
|
+
observed_at: z.ZodString;
|
|
70
|
+
labels: z.ZodArray<z.ZodObject<{
|
|
71
|
+
basename: z.ZodString;
|
|
72
|
+
path_sha256: z.ZodString;
|
|
73
|
+
}, z.core.$strict>>;
|
|
74
|
+
}, z.core.$strict>;
|
|
75
|
+
export type CollectorDeviceRootsMetadata = z.infer<typeof CollectorDeviceRootsMetadataSchema>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The proof that a collector is alive, and what it is allowed to look at.
|
|
3
|
+
*
|
|
4
|
+
* BLI-3551. Until now the ONLY writer of
|
|
5
|
+
* `ambient_collector_devices.last_seen_at` was the ambient ingest path
|
|
6
|
+
* (`apps/dashboard/src/lib/ambient/ingest.ts`), which runs when there is an
|
|
7
|
+
* envelope to send. A machine whose operator works entirely outside the
|
|
8
|
+
* approved roots sends no envelope, so it goes quiet — and a quiet machine and
|
|
9
|
+
* a dead machine look exactly the same from the dashboard. One device ticked
|
|
10
|
+
* 377 times in 38 hours and the last-seen column did not move once.
|
|
11
|
+
*
|
|
12
|
+
* The heartbeat closes that. It is deliberately NOT an ambient envelope: the
|
|
13
|
+
* envelope requires a work context, a session reference and at least one event,
|
|
14
|
+
* and inventing all three per tick would create a work-session row every 15
|
|
15
|
+
* minutes for a machine that collected nothing. A separate door with its own
|
|
16
|
+
* tiny schema costs less and says only what it means.
|
|
17
|
+
*
|
|
18
|
+
* The approved roots ride along as LABELS and never as paths. A root is
|
|
19
|
+
* `{ basename, path_sha256 }`: the basename is the folder name a person would
|
|
20
|
+
* recognise (`BLI`, `repos`), the hash is the stable identity of the normalized
|
|
21
|
+
* absolute path, so two machines pointing at the same folder are comparable
|
|
22
|
+
* without either path leaving the device. This is the same rule the session
|
|
23
|
+
* rows already follow with `cwd_basename` / `cwd_hash`.
|
|
24
|
+
*/
|
|
25
|
+
import { z } from "zod";
|
|
26
|
+
import { IsoDateTimeSchema, Sha256Schema, VersionStringSchema } from "./common.js";
|
|
27
|
+
export const COLLECTOR_HEARTBEAT_SCHEMA_VERSION = "collector-heartbeat.v1";
|
|
28
|
+
/** The most roots one machine may declare. Well above any real operator. */
|
|
29
|
+
export const COLLECTOR_HEARTBEAT_MAX_ROOTS = 40;
|
|
30
|
+
const RootBasenameSchema = z
|
|
31
|
+
.string()
|
|
32
|
+
.trim()
|
|
33
|
+
.min(1)
|
|
34
|
+
.max(160)
|
|
35
|
+
// A basename, never a path: a separator here would mean somebody sent the
|
|
36
|
+
// whole location of someone's work folder.
|
|
37
|
+
.regex(/^[^/\\]+$/u);
|
|
38
|
+
export const CollectorRootLabelSchema = z
|
|
39
|
+
.object({
|
|
40
|
+
basename: RootBasenameSchema,
|
|
41
|
+
/** sha256 of the normalized absolute path. The path itself never travels. */
|
|
42
|
+
path_sha256: Sha256Schema,
|
|
43
|
+
})
|
|
44
|
+
.strict();
|
|
45
|
+
/** What the last collection tick decided, in the collector's own vocabulary. */
|
|
46
|
+
export const CollectorHeartbeatSyncStatusSchema = z.enum([
|
|
47
|
+
"ok",
|
|
48
|
+
"fail",
|
|
49
|
+
"skipped",
|
|
50
|
+
"not_run",
|
|
51
|
+
]);
|
|
52
|
+
export const CollectorHeartbeatSchema = z
|
|
53
|
+
.object({
|
|
54
|
+
schema_version: z.literal(COLLECTOR_HEARTBEAT_SCHEMA_VERSION),
|
|
55
|
+
generated_at: IsoDateTimeSchema,
|
|
56
|
+
collector_version: VersionStringSchema,
|
|
57
|
+
/** The DEVICE's platform (darwin/win32); the server's would be the runtime's. */
|
|
58
|
+
os_platform: z
|
|
59
|
+
.string()
|
|
60
|
+
.trim()
|
|
61
|
+
.min(1)
|
|
62
|
+
.max(32)
|
|
63
|
+
.regex(/^[a-z0-9_-]+$/iu)
|
|
64
|
+
.optional(),
|
|
65
|
+
roots: z.array(CollectorRootLabelSchema).max(COLLECTOR_HEARTBEAT_MAX_ROOTS),
|
|
66
|
+
/**
|
|
67
|
+
* How the tick that produced this heartbeat went, so a machine that is
|
|
68
|
+
* alive AND failing is distinguishable from one that is alive and idle
|
|
69
|
+
* without waiting for the install-events outbox to drain.
|
|
70
|
+
*/
|
|
71
|
+
last_sync_status: CollectorHeartbeatSyncStatusSchema.default("not_run"),
|
|
72
|
+
/**
|
|
73
|
+
* A reason label, never prose: `nothing_in_root:47`, `auth_failed`,
|
|
74
|
+
* `sync_already_running`. Bounded and charset-restricted so nothing
|
|
75
|
+
* content-bearing can ride in on it.
|
|
76
|
+
*/
|
|
77
|
+
last_sync_reason: z
|
|
78
|
+
.string()
|
|
79
|
+
.trim()
|
|
80
|
+
.min(1)
|
|
81
|
+
.max(120)
|
|
82
|
+
.regex(/^[a-z0-9_:.-]+$/iu)
|
|
83
|
+
.optional(),
|
|
84
|
+
/** Counts only — how many sessions the tick saw, and how many were outside. */
|
|
85
|
+
sessions_observed: z.number().int().nonnegative().max(1_000_000).optional(),
|
|
86
|
+
sessions_outside_root: z
|
|
87
|
+
.number()
|
|
88
|
+
.int()
|
|
89
|
+
.nonnegative()
|
|
90
|
+
.max(1_000_000)
|
|
91
|
+
.optional(),
|
|
92
|
+
})
|
|
93
|
+
.strict();
|
|
94
|
+
/**
|
|
95
|
+
* What the device row's `metadata.roots` holds after a heartbeat lands.
|
|
96
|
+
*
|
|
97
|
+
* Stored as its own key rather than merged into the row's top level so an
|
|
98
|
+
* older reader of `metadata` is unaffected, and so "which folders is this
|
|
99
|
+
* machine allowed to see, and when did it last say so" is one lookup.
|
|
100
|
+
*/
|
|
101
|
+
export const CollectorDeviceRootsMetadataSchema = z
|
|
102
|
+
.object({
|
|
103
|
+
observed_at: IsoDateTimeSchema,
|
|
104
|
+
labels: z.array(CollectorRootLabelSchema).max(COLLECTOR_HEARTBEAT_MAX_ROOTS),
|
|
105
|
+
})
|
|
106
|
+
.strict();
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -92,6 +92,54 @@ export declare const DELIVERY_BACKOFF_HOLDING = "delivery_backoff_holding";
|
|
|
92
92
|
* retry.
|
|
93
93
|
*/
|
|
94
94
|
export declare const NOT_UPLOADABLE_ATTRIBUTION_STATE = "not_uploadable_attribution_state";
|
|
95
|
+
/**
|
|
96
|
+
* The four answers `begin` gives when an object key is already spoken for.
|
|
97
|
+
*
|
|
98
|
+
* They are the route's own strings
|
|
99
|
+
* (`apps/dashboard/src/app/api/ambient/evidence/upload/begin/route.ts`), named
|
|
100
|
+
* here so the label the server writes and the label the collector classifies
|
|
101
|
+
* are the same string by construction — the same rule as
|
|
102
|
+
* `STORAGE_REJECTED_OBJECT_TOO_LARGE`.
|
|
103
|
+
*
|
|
104
|
+
* All four are `deterministic`: `begin` compared what is already durable
|
|
105
|
+
* against what this sync is offering and said no. The identical offer gets the
|
|
106
|
+
* identical no, on every tick, forever — which is exactly what four machines
|
|
107
|
+
* did for 38 hours (BLI-3552: ian 157 ticks, veetesh 196, rushik 45, viet 30).
|
|
108
|
+
* They were `unknown` before, and `unknown` is retried.
|
|
109
|
+
*
|
|
110
|
+
* They are NOT interchangeable in what resolves them, and
|
|
111
|
+
* `isRekeyableUploadConflict` is where that difference lives.
|
|
112
|
+
*/
|
|
113
|
+
export declare const HASH_MISMATCH_COMMITTED_OBJECT = "hash_mismatch_committed_object";
|
|
114
|
+
export declare const BYTE_SIZE_MISMATCH_COMMITTED_OBJECT = "byte_size_mismatch_committed_object";
|
|
115
|
+
export declare const COMMITTED_OBJECT_RECEIPT_MISMATCH = "committed_object_receipt_mismatch";
|
|
116
|
+
export declare const OBJECT_KEY_OWNED_BY_OTHER_OPERATOR = "object_key_owned_by_other_operator";
|
|
117
|
+
/**
|
|
118
|
+
* Whether a different NAME would settle this conflict.
|
|
119
|
+
*
|
|
120
|
+
* True for exactly one reason. `hash_mismatch_committed_object` means the key
|
|
121
|
+
* holds other bytes that are already durable and these bytes have never been
|
|
122
|
+
* stored anywhere: nobody is wrong, the key was simply not specific enough to
|
|
123
|
+
* tell the two apart. Re-keying to a name that carries this content's own hash
|
|
124
|
+
* stores it beside the old object without touching it — and a committed object
|
|
125
|
+
* is never deleted to make room, in this or any other path.
|
|
126
|
+
*
|
|
127
|
+
* False for the other three, and each for its own reason:
|
|
128
|
+
* - `byte_size_mismatch_committed_object` — same hash, different size. One of
|
|
129
|
+
* the two receipts is lying about bytes nobody can re-derive; a second copy
|
|
130
|
+
* under a second name would not say which.
|
|
131
|
+
* - `committed_object_receipt_mismatch` — the ledger and Storage disagree about
|
|
132
|
+
* an object that is already there. That is a server-side repair (the drain),
|
|
133
|
+
* not a client-side rename.
|
|
134
|
+
* - `object_key_owned_by_other_operator` — a key inside another person's
|
|
135
|
+
* namespace. Renaming around an ownership boundary is precisely what the
|
|
136
|
+
* boundary exists to stop.
|
|
137
|
+
*
|
|
138
|
+
* Those three are terminal for this object: the collector records the reason
|
|
139
|
+
* and stops offering it, which is what makes the failure visible instead of
|
|
140
|
+
* infinite.
|
|
141
|
+
*/
|
|
142
|
+
export declare function isRekeyableUploadConflict(reason: string | null | undefined): boolean;
|
|
95
143
|
/**
|
|
96
144
|
* Compose the withheld-by-attribution reason for one session.
|
|
97
145
|
*
|
|
@@ -98,6 +98,58 @@ export const DELIVERY_BACKOFF_HOLDING = "delivery_backoff_holding";
|
|
|
98
98
|
* retry.
|
|
99
99
|
*/
|
|
100
100
|
export const NOT_UPLOADABLE_ATTRIBUTION_STATE = "not_uploadable_attribution_state";
|
|
101
|
+
/**
|
|
102
|
+
* The four answers `begin` gives when an object key is already spoken for.
|
|
103
|
+
*
|
|
104
|
+
* They are the route's own strings
|
|
105
|
+
* (`apps/dashboard/src/app/api/ambient/evidence/upload/begin/route.ts`), named
|
|
106
|
+
* here so the label the server writes and the label the collector classifies
|
|
107
|
+
* are the same string by construction — the same rule as
|
|
108
|
+
* `STORAGE_REJECTED_OBJECT_TOO_LARGE`.
|
|
109
|
+
*
|
|
110
|
+
* All four are `deterministic`: `begin` compared what is already durable
|
|
111
|
+
* against what this sync is offering and said no. The identical offer gets the
|
|
112
|
+
* identical no, on every tick, forever — which is exactly what four machines
|
|
113
|
+
* did for 38 hours (BLI-3552: ian 157 ticks, veetesh 196, rushik 45, viet 30).
|
|
114
|
+
* They were `unknown` before, and `unknown` is retried.
|
|
115
|
+
*
|
|
116
|
+
* They are NOT interchangeable in what resolves them, and
|
|
117
|
+
* `isRekeyableUploadConflict` is where that difference lives.
|
|
118
|
+
*/
|
|
119
|
+
export const HASH_MISMATCH_COMMITTED_OBJECT = "hash_mismatch_committed_object";
|
|
120
|
+
export const BYTE_SIZE_MISMATCH_COMMITTED_OBJECT = "byte_size_mismatch_committed_object";
|
|
121
|
+
export const COMMITTED_OBJECT_RECEIPT_MISMATCH = "committed_object_receipt_mismatch";
|
|
122
|
+
export const OBJECT_KEY_OWNED_BY_OTHER_OPERATOR = "object_key_owned_by_other_operator";
|
|
123
|
+
/**
|
|
124
|
+
* Whether a different NAME would settle this conflict.
|
|
125
|
+
*
|
|
126
|
+
* True for exactly one reason. `hash_mismatch_committed_object` means the key
|
|
127
|
+
* holds other bytes that are already durable and these bytes have never been
|
|
128
|
+
* stored anywhere: nobody is wrong, the key was simply not specific enough to
|
|
129
|
+
* tell the two apart. Re-keying to a name that carries this content's own hash
|
|
130
|
+
* stores it beside the old object without touching it — and a committed object
|
|
131
|
+
* is never deleted to make room, in this or any other path.
|
|
132
|
+
*
|
|
133
|
+
* False for the other three, and each for its own reason:
|
|
134
|
+
* - `byte_size_mismatch_committed_object` — same hash, different size. One of
|
|
135
|
+
* the two receipts is lying about bytes nobody can re-derive; a second copy
|
|
136
|
+
* under a second name would not say which.
|
|
137
|
+
* - `committed_object_receipt_mismatch` — the ledger and Storage disagree about
|
|
138
|
+
* an object that is already there. That is a server-side repair (the drain),
|
|
139
|
+
* not a client-side rename.
|
|
140
|
+
* - `object_key_owned_by_other_operator` — a key inside another person's
|
|
141
|
+
* namespace. Renaming around an ownership boundary is precisely what the
|
|
142
|
+
* boundary exists to stop.
|
|
143
|
+
*
|
|
144
|
+
* Those three are terminal for this object: the collector records the reason
|
|
145
|
+
* and stops offering it, which is what makes the failure visible instead of
|
|
146
|
+
* infinite.
|
|
147
|
+
*/
|
|
148
|
+
export function isRekeyableUploadConflict(reason) {
|
|
149
|
+
if (!reason)
|
|
150
|
+
return false;
|
|
151
|
+
return baseUploadFailureReason(reason) === HASH_MISMATCH_COMMITTED_OBJECT;
|
|
152
|
+
}
|
|
101
153
|
/** Longest label the ambient envelope's `SafeLabelSchema` accepts. */
|
|
102
154
|
const MAX_UPLOAD_REASON_LENGTH = 120;
|
|
103
155
|
/**
|
|
@@ -149,6 +201,17 @@ const UPLOAD_FAILURE_CLASSES = {
|
|
|
149
201
|
// The file itself is the problem, and it will be the same size and the same
|
|
150
202
|
// shape on the next pass. Retrying is a promise nobody can keep.
|
|
151
203
|
file_too_large: "deterministic",
|
|
204
|
+
// A declared image above IMAGE_EVIDENCE_MAX_VALIDATION_BYTES (BLI-3067). The
|
|
205
|
+
// commit route deliberately KEEPS the staged chunks for these so that raising
|
|
206
|
+
// the bound turns the identical row into a commit that works — and
|
|
207
|
+
// `docs/runbooks/cockpit-stuck-evidence-uploads.md` has always documented it
|
|
208
|
+
// as deterministic. The entry was missing here, so it classified `unknown`,
|
|
209
|
+
// `isPermanentUploadFailure` answered false, and `begin`'s terminal-failure
|
|
210
|
+
// guard fell through to the reset that deletes those very chunk receipts —
|
|
211
|
+
// destroying the retained staging on the next sync and re-uploading the same
|
|
212
|
+
// oversized image forever. That is the BLI-2528 loop reappearing inside the
|
|
213
|
+
// code written to end it (BLI-3408).
|
|
214
|
+
image_too_large_to_validate: "deterministic",
|
|
152
215
|
// Storage looked at the finished object and said no. The bytes are already
|
|
153
216
|
// final and content-addressed, so the next attempt presents the identical
|
|
154
217
|
// object to the identical rule (BLI-2528). Retrying these is what let 76
|
|
@@ -158,6 +221,22 @@ const UPLOAD_FAILURE_CLASSES = {
|
|
|
158
221
|
// The content-addressed key already holds different bytes. One of the two is
|
|
159
222
|
// wrong and repeating the write cannot decide which; a human has to look.
|
|
160
223
|
object_conflict: "deterministic",
|
|
224
|
+
// `begin`'s four committed-object conflicts (BLI-3552). Deterministic for the
|
|
225
|
+
// same reason as `object_conflict` above — the route looked at both sides and
|
|
226
|
+
// answered no — and the difference between them is not the class but the
|
|
227
|
+
// remedy, which `isRekeyableUploadConflict` holds. Before this entry existed
|
|
228
|
+
// they classified `unknown`, `retryableFailedOutcomes` counted them as
|
|
229
|
+
// rescuable, and four machines re-offered the identical bytes every 15
|
|
230
|
+
// minutes while every one of those syncs was marked failed.
|
|
231
|
+
[HASH_MISMATCH_COMMITTED_OBJECT]: "deterministic",
|
|
232
|
+
[BYTE_SIZE_MISMATCH_COMMITTED_OBJECT]: "deterministic",
|
|
233
|
+
[COMMITTED_OBJECT_RECEIPT_MISMATCH]: "deterministic",
|
|
234
|
+
[OBJECT_KEY_OWNED_BY_OTHER_OPERATOR]: "deterministic",
|
|
235
|
+
// The drain's terminal answer for a stranded row whose key is already held by
|
|
236
|
+
// a different durable object (BLI-3552). Nothing on the server can finish it:
|
|
237
|
+
// the final write uses `upsert: false` on purpose, so the row would loop as
|
|
238
|
+
// `skipped_storage_error` for as long as anyone kept running the pass.
|
|
239
|
+
object_key_holds_other_content: "deterministic",
|
|
161
240
|
// Attribution never produced a target, so there is nowhere to put the object.
|
|
162
241
|
// Widening discovery can change this; running the same scan again cannot.
|
|
163
242
|
cwd_outside_scanned_worktrees: "deterministic",
|