Setting the file. One moment.
Bi Sink · Rp Telemetry · wix/skills · Skills Docs
ContentsBack to the top of the page
Number 45.3
Position 3 of 5
Type JavaScript
Size 12 KB
Lines 273 lib/ bi-sink.js
JavaScript · 273 lines · 12 KB
15 // 2. Every GUID-typed field must be a real GUID; a non-hex value fails
16 // validation before routing and drops the whole event.
17 // 3. Every row carries `logged_user_id` = the operator's wix_user_id (GUID).
18 //
19 // frog's HTTP status is not an ingestion receipt — only a Trino read-back proves
20 // arrival. The recorder treats any non-2xx or network error as a push failure
21 // (journaled into telemetry_health); a 2xx merely means "handed to frog".
22 //
23 // Env switches (tests / offline dev only — production runs always emit):
24 // RP_TELEMETRY_BI_ENDPOINT override the frog endpoint (contract-test mock)
25 // RP_TELEMETRY_BI_DISABLED=1 skip all pushes entirely
26
27 const SRC = 10 ;
28 const EVID_RUN = 5012 ; // replatform_run — run-grain lifecycle, phase started|finalized
29 const EVID_RUN_EVENT = 5013 ; // replatform_run_event — one row per observation class
30 const DEFAULT_ENDPOINT = 'https://frog.wix.com/migration-data-validator' ;
31 const MAX_EVENTS_PER_REQUEST = 100 ;
32 const MAX_BODY_BYTES = 240 * 1024 ;
33 const PUSH_TIMEOUT_MS = 8000 ;
34
35 const GUID_RE = / ^ [0-9a-f] {8} - [0-9a-f] {4} - [0-9a-f] {4} - [0-9a-f] {4} - [0-9a-f] {12}$ / i ;
36
37 function isGuid ( value ) {
38 return typeof value === 'string' && GUID_RE . test (value);
39 }
40
41 function disabled () {
42 return process.env. RP_TELEMETRY_BI_DISABLED === '1' ;
43 }
44
45 function endpoint () {
46 return process.env. RP_TELEMETRY_BI_ENDPOINT || DEFAULT_ENDPOINT ;
47 }
48
49 // Empty-value convention: unknown fields are omitted, never sent as null/"".
50 // JSON-typed schema fields (arrays/objects) are JSON-serialized into their value.
51 // GUID-typed fields are dropped unless they hold a real GUID (hard rule 2 —
52 // sending a non-GUID would silently drop the whole event, not just the field).
53 function put ( fields , key , value , { json = false , guid = false } = {}) {
54 if (value === undefined || value === null ) return ;
55 if (guid && ! isGuid (value)) return ;
56 fields[key] = json ? JSON . stringify (value) : value;
57 }
58
59 // `replatform_run` phase:started — built from the journal's run_start record, so
60 // its content is a pure function of run start and re-pushes are byte-identical
61 // (dedup key: (run_id, phase), latest row wins). `wixUserId` is the one late
62 // addition: identity may arrive via a later dims call, and the row is only
63 // sendable once it has (rule 3), so it rides along when known.
64 function startedRunRow ( runStart , wixUserId ) {
65 const dims = runStart.dims || {};
66 const f = { phase: 'started' };
67 put (f, 'runId' , runStart.run_id, { guid: true });
68 put (f, 'projectId' , runStart.project_id);
69 put (f, 'attempt' , runStart.attempt);
70 put (f, 'sessionCount' , 1 );
71 put (f, 'schemaVersions' , runStart.schema_version);
72 put (f, 'skillsVersion' , runStart.skills_version);
73 put (f, 'runtimeEnv' , runStart.runtime_env, { json: true });
74 put (f, 'wixUserId' , wixUserId, { guid: true });
75 put (f, 'sourcePlatform' , dims.source_platform);
76 put (f, 'sourcePlatformVersion' , dims.source_platform_version);
77 put (f, 'sourceExtensions' , dims.source_extensions, { json: true });
78 put (f, 'sourceSiteUrl' , dims.source_site_url);
79 put (f, 'sourceAcquisition' , dims.source_acquisition);
80 put (f, 'deliveryMode' , dims.delivery_mode);
81 put (f, 'destinationStrategy' , dims.destination_strategy);
82 put (f, 'runStarted' , runStart.ts);
83 return { evid: EVID_RUN , fields: f };
84 }
85
86 // `replatform_run` phase:finalized — the complete rollup. Local names map to the
87 // as-registered BI names here and nowhere else (see the Registration notes):
88 // schema_version→schemaVersions, verification→verificationType. skills_commit and
89 // discovered_entity_types are local-only (not registered BI columns) and
90 // deliberately not sent — the entity-class demand signal reaches BI through the
91 // `volumes` column; the dim exists for the finalize cross-check, whose flags
92 // travel in telemetryHealth.
93 function finalizedRunRow ( rollup ) {
94 const f = { phase: 'finalized' };
95 put (f, 'runId' , rollup.run_id, { guid: true });
96 put (f, 'projectId' , rollup.project_id);
97 put (f, 'attempt' , rollup.attempt);
98 put (f, 'sessionCount' , rollup.session_count);
99 put (f, 'schemaVersions' , rollup.schema_version);
100 put (f, 'skillsVersion' , rollup.skills_version);
101 put (f, 'runtimeEnv' , rollup.runtime_env, { json: true });
102 put (f, 'wixUserId' , rollup.wix_user_id, { guid: true });
103 put (f, 'siteId' , rollup.site_id, { guid: true });
104 put (f, 'sourcePlatform' , rollup.source_platform);
105 put (f, 'sourcePlatformVersion' , rollup.source_platform_version);
106 put (f, 'sourceExtensions' , rollup.source_extensions, { json: true });
107 put (f, 'sourceSiteUrl' , rollup.source_site_url);
108 put (f, 'sourceAcquisition' , rollup.source_acquisition);
109 put (f, 'deliveryMode' , rollup.delivery_mode);
110 put (f, 'destinationStrategy' , rollup.destination_strategy);
111 put (f, 'terminalState' , rollup.terminal_state);
112 put (f, 'stoppedAtStage' , rollup.stopped_at_stage);
113 put (f, 'stages' , rollup.stages, { json: true });
114 put (f, 'volumes' , rollup.volumes, { json: true });
115 put (f, 'verificationType' , rollup.verification, { json: true });
116 put (f, 'operatorAcceptance' , rollup.operator_acceptance);
117 put (f, 'telemetryHealth' , rollup.telemetry_health, { json: true });
118 put (f, 'eventCountByType' , rollup.event_count_by_type, { json: true });
119 put (f, 'activeMs' , rollup.active_ms);
120 put (f, 'waitingMs' , rollup.waiting_ms);
121 put (f, 'runStarted' , rollup.run_started);
122 put (f, 'runEnded' , rollup.run_ended);
123 put (f, 'bundleManifest' , rollup.bundle_manifest, { json: true });
124 // spec 0039 §5: the transcript_digest is a run-grain fact, so it rides the
125 // existing finalized row rather than opening a new BI call — one JSON field,
126 // matching how `stages`/`volumes` already travel, rather than one registered
127 // column per sub-field. A run with no digest (offline recompute skipped, or
128 // a run that predates 1.5.0) simply omits the field, same as any unknown.
129 //
130 // Known limitation (verified live, spec 0039 §5): as of 2026-08-17, `bi_events_raw`
131 // shows evid 5012 rows with an EMPTY dimensions/measures map for every one of them —
132 // this field will land alongside `terminalState`/`volumes`/every other field already
133 // on this row and be equally unqueryable until whoever owns the BI schema resolves
134 // why 5012/5013 aren't materializing their payload. Nothing to fix here: this `put()`
135 // call matches the pattern every other field on the row already uses correctly.
136 put (f, 'transcriptDigest' , rollup.transcript_digest, { json: true });
137 return { evid: EVID_RUN , fields: f };
138 }
139
140 // `replatform_run_event` — one row per folded observation class. Re-pushing a
141 // class after more occurrences folded in is by design: the dedup key is
142 // (run_id, seq) and the latest row carries the cumulative count. Renames:
143 // event_type→eventTypeName, error_code→errorType, expected/actual→
144 // expectedSkill/actualSkill.
145 function eventRow ( folded ) {
146 const f = {};
147 put (f, 'runId' , folded.run_id, { guid: true });
148 put (f, 'seq' , folded.seq);
149 put (f, 'count' , folded.count);
150 put (f, 'stage' , folded.stage);
151 put (f, 'skill' , folded.skill);
152 put (f, 'eventTypeName' , folded.event_type);
153 put (f, 'subtype' , folded.subtype);
154 put (f, 'entityType' , folded.entity_type);
155 put (f, 'severity' , folded.severity);
156 put (f, 'wixApiSurface' , folded.wix_api_surface);
157 put (f, 'sourceApiSurface' , folded.source_api_surface);
158 put (f, 'wixAppId' , folded.wix_app_id, { guid: true });
159 put (f, 'errorType' , folded.error_code);
160 put (f, 'decisionPoint' , folded.decision_point);
161 put (f, 'retryCount' , folded.retry_count);
162 put (f, 'recovered' , folded.recovered);
163 put (f, 'whatHappened' , folded.what_happened);
164 put (f, 'expectedSkill' , folded.expected);
165 put (f, 'actualSkill' , folded.actual);
166 put (f, 'observedShapes' , folded.observed_shapes, { json: true });
167 put (f, 'shapesSeen' , folded.shapes_seen);
168 put (f, 'evidenceRefs' , folded.evidence_refs, { json: true });
169 return { evid: EVID_RUN_EVENT , fields: f };
170 }
171
172 function envelopeBody ( evid , events ) {
173 return JSON . stringify ({ dt: 0 , g: { src: SRC , evid }, e: events });
174 }
175
176 // A single event over the body cap sheds its unbounded-ish JSON fields before
177 // being declared unsendable. Truncation is surfaced (telemetry_health flag),
178 // never silent.
179 const SHEDDABLE_FIELDS = [ 'observedShapes' , 'evidenceRefs' , 'bundleManifest' , 'stages' , 'volumes' ];
180
181 function shedOversize ( event ) {
182 const f = { ... event.f };
183 let shed = false ;
184 for ( const key of SHEDDABLE_FIELDS ) {
185 if (f[key] === undefined ) continue ;
186 delete f[key];
187 shed = true ;
188 }
189 return shed ? { ... event, f } : null ;
190 }
191
192 async function sendBatch ( evid , events ) {
193 const body = envelopeBody (evid, events);
194 try {
195 const res = await fetch ( endpoint (), {
196 method: 'POST' ,
197 headers: { 'content-type' : 'application/json' },
198 body,
199 signal: AbortSignal. timeout ( PUSH_TIMEOUT_MS ),
200 });
201 if ( ! res.ok) return `http_${ res . status }` ;
202 return null ;
203 } catch (error) {
204 return error && error.name === 'TimeoutError' ? 'timeout' : 'network_error' ;
205 }
206 }
207
208 // Push rows (from the builders above) as the given operator. Never throws:
209 // returns { sent, failures: [{evid, rows, detail}], truncated, skipped } and
210 // the caller journals the failures — telemetry must never block a migration.
211 // A disabled sink reports the rows it swallowed in `skipped` instead of
212 // masquerading as a successful push — the caller journals those too, so a
213 // kill-switch leaking into a production session is visible, never silent.
214 async function pushRows ( rows , loggedUserId ) {
215 const out = { sent: 0 , failures: [], truncated: 0 , skipped: 0 };
216 if (rows. length === 0 ) return out;
217 if ( disabled ()) {
218 out.skipped = rows. length ;
219 return out;
220 }
221 if ( ! isGuid (loggedUserId)) {
222 out.failures. push ({ evid: null , rows: rows. length , detail: 'no_operator_identity' });
223 return out;
224 }
225 const byEvid = new Map ();
226 for ( const row of rows) {
227 if ( ! byEvid. has (row.evid)) byEvid. set (row.evid, []);
228 byEvid. get (row.evid). push ({ dt: 0 , f: { ... row.fields, logged_user_id: loggedUserId } });
229 }
230 for ( const [ evid , events ] of byEvid) {
231 const queue = [events];
232 while (queue. length > 0 ) {
233 let chunk = queue. shift ();
234 if (chunk. length > MAX_EVENTS_PER_REQUEST ) {
235 const mid = Math. ceil (chunk. length / 2 );
236 queue. unshift (chunk. slice ( 0 , mid), chunk. slice (mid));
237 continue ;
238 }
239 if (Buffer. byteLength ( envelopeBody (evid, chunk)) > MAX_BODY_BYTES ) {
240 if (chunk. length > 1 ) {
241 const mid = Math. ceil (chunk. length / 2 );
242 queue. unshift (chunk. slice ( 0 , mid), chunk. slice (mid));
243 continue ;
244 }
245 const slimmed = shedOversize (chunk[ 0 ]);
246 if ( ! slimmed || Buffer. byteLength ( envelopeBody (evid, [slimmed])) > MAX_BODY_BYTES ) {
247 out.failures. push ({ evid, rows: 1 , detail: 'oversized_event' });
248 continue ;
249 }
250 out.truncated += 1 ;
251 chunk = [slimmed];
252 }
253 const failureDetail = await sendBatch (evid, chunk);
254 if (failureDetail) out.failures. push ({ evid, rows: chunk. length , detail: failureDetail });
255 else out.sent += chunk. length ;
256 }
257 }
258 return out;
259 }
260
261 module . exports = {
262 SRC,
263 EVID_RUN,
264 EVID_RUN_EVENT,
265 MAX_EVENTS_PER_REQUEST,
266 MAX_BODY_BYTES,
267 isGuid,
268 disabled,
269 startedRunRow,
270 finalizedRunRow,
271 eventRow,
272 pushRows,
273 };