jevstrudel.git / worker / src / events-schema.ts
1// What each telemetry event means, column by column: the one place the
2// Worker's writer (events.ts) and the reader (`nix run .#events`,
3// tools/events/) both take it from. Plain data with no imports, so Node
4// runs this file as it is.
5//
6// Every event goes to one Workers Analytics Engine dataset (the EVENTS
7// binding, wrangler.json), which is a single table of positional columns:
8//
9//   index1     the event's name ('jev.relay'): what a query filters on, and
10//              the key Analytics Engine samples by, so a busy event is
11//              thinned without touching a rare one (weigh rows by
12//              _sample_interval when counting or averaging)
13//   blob1…20   strings, per event below; null when not known
14//   double1    which of the event's doubles are known: bit (k - 2) is set
15//              when double k holds a value, since a double cannot be null
16//              (`bitAnd(toUInt32(double1), 1) > 0` for double2)
17//   double2…20 numbers, per event below; 0 when not known (see double1)
18//
19// A column's meaning is fixed once written: old rows keep it for the
20// dataset's three months. So a field is only ever added, at the next free
21// column; never renamed onto another column, moved, or reused. A new
22// meaning is a new field (or a new event). events.test.ts checks every
23// event's columns run from blob1 and double2 with no gap or repeat.
24//
25// What an event may hold: timing, sizes, counts, statuses and bounded
26// labels. Never an address, a visitor or rate-limit key, a request body,
27// or a header as sent (worker/CLAUDE.md, "Logs carry no visitor"). A blob
28// is a label to group by, not a place for text.
29
30export const EVENTS = {
31  'jev.relay': {
32    about: 'One per call to the Jev relay (relay.ts), with the same fields as its jev.relay log line.',
33    blobs: {
34      outcome: {
35        column: 'blob1',
36        about:
37          'refused (the relay answered itself: method, key, rate limit, size or shape), forwarded (TypeSafe answered, whatever its status), cached (the same request was answered within the hour; TypeSafe not asked) or unreachable (TypeSafe could not be reached)',
38      },
39      colo: { column: 'blob2', about: 'the Cloudflare data centre that ran the call' },
40    },
41    doubles: {
42      status: { column: 'double2', about: 'the HTTP status the page got' },
43      upstreamMs: {
44        column: 'double3',
45        about: "ms until TypeSafe's response headers; unknown when TypeSafe was not asked (refused, cached)",
46      },
47      requestBytes: { column: 'double4', about: 'the body as forwarded; unknown when refused before reading it' },
48      sectionInS: {
49        column: 'double5',
50        about: 'seconds before its section began that the page sent the call (negative once begun); unknown outside a song',
51      },
52      attempt: { column: 'double6', about: "the page's attempt: 0, then its retries" },
53    },
54  },
55  'jev.budget.exhausted': {
56    about:
57      "One per signed-in user and UTC day, on the relay call that spent the last of that user's daily Jev budget (budget.ts). The user is not recorded; counting these by day is how many listeners ran out.",
58    blobs: {
59      colo: { column: 'blob1', about: 'the Cloudflare data centre that ran the call' },
60    },
61    doubles: {
62      limit: { column: 'double2', about: 'the daily budget that was spent (JEV_DAILY_PER_USER at the time)' },
63      secondsToReset: { column: 'double3', about: 'seconds from then until the budget resets at UTC midnight' },
64    },
65  },
66  'jev.screen': {
67    about:
68      "One per attempt to screen a listener's item before it is public (content-jobs.ts): Jev's verdict, or why it is still pending. The item and its author are not recorded.",
69    blobs: {
70      kind: { column: 'blob1', about: 'what was screened: revision (a song), cover (its alt text), comment or pitch' },
71      outcome: { column: 'blob2', about: "fine (now public), spam or abuse (held), or pending (not screened this time; see why)" },
72      why: {
73        column: 'blob3',
74        about:
75          "for pending: budget (the author's daily Jev budget is spent), budget-unavailable, unreachable (TypeSafe, or no key), upstream (TypeSafe answered an error) or unanswered (no verdict in the answer); null otherwise",
76      },
77    },
78    doubles: {
79      status: { column: 'double2', about: "TypeSafe's HTTP status; unknown when it was not asked or not reached" },
80      upstreamMs: { column: 'double3', about: "ms until TypeSafe's response headers; unknown when not asked" },
81      attempt: { column: 'double4', about: 'which attempt this was: 0, then each retry' },
82      confidence: { column: 'double5', about: "Jev's confidence in the verdict, 0 to 1; unknown when pending or not given" },
83      requestBytes: { column: 'double6', about: 'the screening request as sent; unknown when not sent' },
84    },
85  },
86  'jev.score': {
87    about:
88      "One per attempt to score a listener's song revision with the art critic (content-jobs.ts), after it was screened fine. The song and its author are not recorded.",
89    blobs: {
90      outcome: { column: 'blob1', about: 'scored, or pending (not scored this time; see why)' },
91      why: { column: 'blob2', about: "for pending, as jev.screen's why; null when scored" },
92    },
93    doubles: {
94      status: { column: 'double2', about: "the worst HTTP status of the runs' TypeSafe calls; unknown when not asked or not reached" },
95      upstreamMs: { column: 'double3', about: 'ms until the slowest run had its response headers; unknown when not asked' },
96      attempt: { column: 'double4', about: 'which attempt this was: 0, then each retry' },
97      art: { column: 'double5', about: 'the mean art of the runs, 0 to 1; unknown when pending' },
98      runs: { column: 'double6', about: 'how many runs were asked (and charged)' },
99    },
100  },
101} as const;