jevstrudel.git / worker / src / events.ts
1// Telemetry: event(env, name, { blobs, doubles }) writes one data point to
2// Workers Analytics Engine (the EVENTS binding) for an event declared in
3// events-schema.ts, which says what every column means and how the dataset
4// is laid out. The fields are named, and typed from the schema, so an event
5// can only be written with exactly the fields it declares; their columns
6// come from the schema, never from the call.
7//
8//   event(env, 'jev.relay', {
9//     blobs: { outcome: 'forwarded', colo: 'DFW' },
10//     doubles: { status: 200, upstreamMs: 412, requestBytes: 1830, sectionInS: 3.2, attempt: 0 },
11//   });
12//
13// A null blob is written as null; a null (or non-finite) double as 0 with
14// its bit in double1 left clear. Writing never throws into the request:
15// Analytics Engine writes in the background, and a failure to hand it the
16// point is logged, not raised. Read it back with `nix run .#events`
17// (tools/events/).
18import { EVENTS } from './events-schema';
19
20export { EVENTS };
21type Schema = typeof EVENTS;
22export type EventName = keyof Schema;
23export type EventFields<N extends EventName> = {
24  blobs: { [K in keyof Schema[N]['blobs']]: string | null };
25  doubles: { [K in keyof Schema[N]['doubles']]: number | null };
26};
27
28type Column = { column: string };
29const position = (column: string, kind: 'blob' | 'double') => {
30  const m = new RegExp(`^${kind}(\\d+)$`).exec(column);
31  if (!m) throw new Error(`not a ${kind} column: ${column}`);
32  return Number(m[1]);
33};
34
35// The data point for an event, as Analytics Engine takes it.
36export function dataPoint<N extends EventName>(name: N, fields: EventFields<N>): AnalyticsEngineDataPoint {
37  const schema = EVENTS[name] as unknown as { blobs: Record<string, Column>; doubles: Record<string, Column> };
38  const given = fields as unknown as { blobs: Record<string, string | null>; doubles: Record<string, number | null> };
39  const blobs: (string | null)[] = [];
40  for (const [field, { column }] of Object.entries(schema.blobs)) blobs[position(column, 'blob') - 1] = given.blobs[field] ?? null;
41  const doubles: number[] = [0];
42  let known = 0;
43  for (const [field, { column }] of Object.entries(schema.doubles)) {
44    const k = position(column, 'double');
45    const value = given.doubles[field];
46    const isKnown = typeof value === 'number' && Number.isFinite(value);
47    doubles[k - 1] = isKnown ? value : 0;
48    if (isKnown) known |= 1 << (k - 2);
49  }
50  doubles[0] = known;
51  return { indexes: [name], blobs, doubles };
52}
53
54export function event<N extends EventName>(
55  env: { EVENTS: AnalyticsEngineDataset },
56  name: N,
57  fields: EventFields<N>,
58): void {
59  try {
60    env.EVENTS.writeDataPoint(dataPoint(name, fields));
61  } catch (e) {
62    console.error({ event: 'events.write', name, error: (e as Error).message });
63  }
64}