| 1 | /** |
| 2 | * Batch -> Workers Analytics Engine data points. |
| 3 | * |
| 4 | * One data point per event. A batch is capped at 200 events |
| 5 | * (`BATCH_MAX_EVENTS`), and Analytics Engine allows 250 data points per Worker |
| 6 | * invocation, so a conforming batch never needs a second pass and never has to |
| 7 | * drop an event to fit. |
| 8 | * |
| 9 | * The column layout is fixed and positional because Analytics Engine columns |
| 10 | * are `blob1..blob20` / `double1..double20` — the names live only in the SQL |
| 11 | * you write. Renumbering a column silently rewrites every historical query, so |
| 12 | * the order below is append-only: to add a field, take the next free slot. |
| 13 | * |
| 14 | * The layout is chosen so the two questions the owner actually has are one |
| 15 | * query each. Both queries are written out in `README.md`: |
| 16 | * |
| 17 | * (a) installs and sessions -> index1 (install_id) + blob1 (event) |
| 18 | * (b) error classes and panic sites -> double11..16 + blob16, one GROUP BY |
| 19 | * |
| 20 | * Every value below comes out of the validated batch body and nothing else. |
| 21 | * This module cannot see anything about the connection — that is enforced by |
| 22 | * `test/no-ip.test.ts`, which fails if the type it takes is ever widened. See |
| 23 | * the red-line comment at the top of `index.ts`. |
| 24 | */ |
| 25 | |
| 26 | import type { Batch, Event } from "./schema"; |
| 27 | import { COUNTER_FIELDS, ERROR_FIELDS, TURN_WALL_FIELDS } from "./schema"; |
| 28 | |
| 29 | /** The subset of `AnalyticsEngineDataset` this Worker uses. */ |
| 30 | export interface DataPointSink { |
| 31 | writeDataPoint(point: { |
| 32 | indexes?: string[]; |
| 33 | blobs?: string[]; |
| 34 | doubles?: number[]; |
| 35 | }): void; |
| 36 | } |
| 37 | |
| 38 | /** One row, in Analytics Engine's positional form. */ |
| 39 | export interface DataPoint { |
| 40 | indexes: string[]; |
| 41 | blobs: string[]; |
| 42 | doubles: number[]; |
| 43 | } |
| 44 | |
| 45 | /** |
| 46 | * Blob column names, in `blob1..blobN` order. Exported so the README's SQL and |
| 47 | * the tests read from one list rather than two. |
| 48 | */ |
| 49 | export const BLOB_COLUMNS = [ |
| 50 | "event", // blob1 |
| 51 | "surface", // blob2 |
| 52 | "os", // blob3 |
| 53 | "arch", // blob4 |
| 54 | "libc", // blob5 |
| 55 | "app_version", // blob6 |
| 56 | "git_sha", // blob7 '' when null (a local build) |
| 57 | "tty", // blob8 'true' | 'false' |
| 58 | "install_kind", // blob9 |
| 59 | "previous_version", // blob10 |
| 60 | "session_source", // blob11 |
| 61 | "duration_bucket", // blob12 |
| 62 | "exit_class", // blob13 |
| 63 | "cold_start_bucket", // blob14 |
| 64 | "providers", // blob15 comma-joined, already sorted and deduplicated |
| 65 | "panic_site", // blob16 |
| 66 | "sent_at", // blob17 the batch timestamp; events carry none |
| 67 | ] as const; |
| 68 | |
| 69 | /** |
| 70 | * Double column names, in `double1..double20` order: the ten counters, the six |
| 71 | * error classes, then the four turn-wall buckets. Exactly 20 — Analytics |
| 72 | * Engine's ceiling — which is why `tty` is a blob. |
| 73 | */ |
| 74 | export const DOUBLE_COLUMNS = [ |
| 75 | ...COUNTER_FIELDS, |
| 76 | ...ERROR_FIELDS, |
| 77 | ...TURN_WALL_FIELDS, |
| 78 | ] as const; |
| 79 | |
| 80 | const EMPTY_DOUBLES: number[] = DOUBLE_COLUMNS.map(() => 0); |
| 81 | |
| 82 | /** Build the rows for one validated batch. */ |
| 83 | export function toDataPoints(batch: Batch): DataPoint[] { |
| 84 | return batch.events.map((event) => toDataPoint(batch, event)); |
| 85 | } |
| 86 | |
| 87 | function toDataPoint(batch: Batch, event: Event): DataPoint { |
| 88 | const blobs = new Array<string>(BLOB_COLUMNS.length).fill(""); |
| 89 | blobs[0] = event.event; |
| 90 | blobs[1] = batch.surface; |
| 91 | blobs[2] = batch.os; |
| 92 | blobs[3] = batch.arch; |
| 93 | blobs[4] = batch.libc; |
| 94 | blobs[5] = batch.app_version; |
| 95 | blobs[6] = batch.git_sha ?? ""; |
| 96 | blobs[7] = batch.tty ? "true" : "false"; |
| 97 | blobs[16] = batch.sent_at; |
| 98 | |
| 99 | let doubles = EMPTY_DOUBLES; |
| 100 | |
| 101 | switch (event.event) { |
| 102 | case "install_or_upgrade": |
| 103 | blobs[8] = event.kind; |
| 104 | blobs[9] = event.previous_version ?? ""; |
| 105 | break; |
| 106 | case "session_start": |
| 107 | blobs[10] = event.source; |
| 108 | break; |
| 109 | case "session_end": |
| 110 | blobs[11] = event.duration_bucket; |
| 111 | blobs[12] = event.exit_class; |
| 112 | blobs[13] = event.cold_start_bucket ?? ""; |
| 113 | blobs[14] = event.providers.join(","); |
| 114 | doubles = [ |
| 115 | ...COUNTER_FIELDS.map((field) => event.counters[field]), |
| 116 | ...ERROR_FIELDS.map((field) => event.errors[field]), |
| 117 | ...TURN_WALL_FIELDS.map((field) => event.turn_wall[field]), |
| 118 | ]; |
| 119 | break; |
| 120 | case "panic": |
| 121 | blobs[15] = event.site; |
| 122 | break; |
| 123 | } |
| 124 | |
| 125 | return { |
| 126 | // The one index. `install_id` is a random v4 UUID that the client rotates |
| 127 | // every 90 days, and it is the only identifier in the schema — which is |
| 128 | // also why `docs/TELEMETRY.md` says no count derived from it is a user |
| 129 | // count. It is the index because both questions group by it or count it. |
| 130 | indexes: [batch.install_id], |
| 131 | blobs, |
| 132 | doubles, |
| 133 | }; |
| 134 | } |
| 135 | |
| 136 | /** Write one batch. `writeDataPoint` is non-blocking and is never awaited. */ |
| 137 | export function writeBatch(sink: DataPointSink, batch: Batch): number { |
| 138 | const points = toDataPoints(batch); |
| 139 | for (const point of points) { |
| 140 | sink.writeDataPoint(point); |
| 141 | } |
| 142 | return points.length; |
| 143 | } |
| 144 |