File size: 7,285 Bytes
ee888e1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
/**
 * Sprint 1 / U5 β€” shadow-mode decision logger.
 *
 * Aggregates per-cluster cooldown decisions for one (user, rule) send
 * and emits ONE summary line per send (not one per cluster). Format
 * matches the existing parity log at `scripts/seed-digest-notifications
 * .mjs:~2122-2131` so Sentry's console-breadcrumb hook can group both
 * logs under the same fingerprint family.
 *
 * Why per-(user, rule), not per-(user, rule, channel): the cooldown
 * decision is per-channel under the U4 key shape, but the operator
 * surface that matters is "did this user-rule send have any would-have-
 * been-suppressed clusters?". Per-channel granularity stays available
 * via the `byChannel` aggregate in the line, but the line is one per
 * send so a busy cron doesn't flood Sentry.
 *
 * `console.log` is the default level. `console.warn` is reserved for
 * decisions where ANY cluster had `classificationMissing: true` β€” that
 * is real telemetry signal worth surfacing in Sentry (the stub
 * classifier hit the conservative-default fallback, which means U6
 * replay needs to spot the gap before Sprint 3's final taxonomy lands).
 *
 * The module is pure β€” accepts a `consoleLike` dep so tests can record
 * lines without touching real console. Returns the formatted line so
 * tests can assert on shape without parsing console output.
 */

import { REASON } from './digest-cooldown-decision.mjs';

/**
 * Aggregate a flat list of per-cluster decisions into the summary
 * counters used in the log line.
 *
 * Every decision is a `{decision, reason, classifiedType,
 * classificationMissing, ...}` object as produced by `evaluateCooldown`.
 * Decisions that came back `null` from the evaluator (mode='off') MUST
 * be filtered by the caller BEFORE invoking this function β€” the logger
 * intentionally has no opinion on the kill-switch state, only on
 * decisions that were actually computed.
 *
 * @param {Array<{ decision: 'allow' | 'suppress', reason: string,
 *   classifiedType: string, classificationMissing: boolean }>} decisions
 * @returns {{
 *   total: number,
 *   allow: number,
 *   suppress: number,
 *   classificationMissing: number,
 *   byReason: Record<string, number>,
 *   byType: Record<string, number>,
 * }}
 */
export function aggregateCooldownDecisions(decisions) {
  /** @type {Record<string, number>} */
  const byReason = {};
  /** @type {Record<string, number>} */
  const byType = {};
  let allow = 0;
  let suppress = 0;
  let classificationMissing = 0;
  if (!Array.isArray(decisions)) {
    return { total: 0, allow: 0, suppress: 0, classificationMissing: 0, byReason, byType };
  }
  for (const d of decisions) {
    if (!d || typeof d !== 'object') continue;
    if (d.decision === 'allow') allow++;
    else if (d.decision === 'suppress') suppress++;
    if (d.classificationMissing) classificationMissing++;
    const reason = typeof d.reason === 'string' ? d.reason : 'unknown';
    byReason[reason] = (byReason[reason] ?? 0) + 1;
    const type = typeof d.classifiedType === 'string' ? d.classifiedType : 'unknown';
    byType[type] = (byType[type] ?? 0) + 1;
  }
  return {
    total: allow + suppress,
    allow,
    suppress,
    classificationMissing,
    byReason,
    byType,
  };
}

/**
 * Render the by-reason / by-type maps as a stable, parseable inline
 * string for the Sentry-friendly log line: `k1=v1,k2=v2` sorted by key.
 *
 * Sorting is load-bearing β€” without it two ticks with the same counter
 * distribution but different insertion order would produce different
 * log lines, which breaks Sentry fingerprint grouping (the breadcrumb
 * hook hashes the line text). Empty input renders as `none` so the
 * field is always non-empty (avoids `bytype= ` parsing ambiguity).
 *
 * @param {Record<string, number>} map
 * @returns {string}
 */
function renderSortedKv(map) {
  const keys = Object.keys(map).sort();
  if (keys.length === 0) return 'none';
  return keys.map((k) => `${k}=${map[k]}`).join(',');
}

/**
 * Emit one shadow-mode summary line per (user, rule) send. Caller
 * collects per-cluster per-channel decisions during the send loop and
 * passes them as a flat array.
 *
 * @param {object} args
 * @param {string} args.userId
 * @param {string} args.ruleId β€” same composite shape used by the U4
 *   delivered-log writer (`${variant}:${lang}:${sensitivity}`).
 * @param {string} args.slot β€” issueSlot string (used by the U6 replay
 *   harness to bucket per-day; included in the line so an operator can
 *   grep one slot's worth of decisions).
 * @param {Array<object>} args.decisions β€” per-cluster decisions; null
 *   entries (mode='off' short-circuits) MUST be filtered by the caller
 *   before the line is emitted, OR set
 *   `args.skipEmptyAggregate=true` to no-op when zero decisions remain.
 * @param {boolean} [args.skipEmptyAggregate=true] β€” when true and the
 *   filtered decisions list is empty, the function returns null without
 *   emitting (avoids a `cooldown_decision total=0` line on every cron
 *   tick when mode='off').
 * @param {{ log?: (line: string) => void, warn?: (line: string) => void }} [args.consoleLike]
 * @returns {string | null} β€” the line that was emitted, or null if
 *   skipped. Useful for tests that want to assert on shape.
 */
export function emitCooldownShadowLog(args) {
  const userId = typeof args?.userId === 'string' ? args.userId : 'unknown';
  const ruleId = typeof args?.ruleId === 'string' ? args.ruleId : 'unknown';
  const slot = typeof args?.slot === 'string' ? args.slot : 'unknown';
  const decisions = Array.isArray(args?.decisions) ? args.decisions.filter(Boolean) : [];
  const skipEmpty = args?.skipEmptyAggregate !== false;
  const log = args?.consoleLike?.log ?? ((line) => console.log(line));
  const warn = args?.consoleLike?.warn ?? ((line) => console.warn(line));

  if (decisions.length === 0) {
    if (skipEmpty) return null;
    // Non-skip path: still emit so operators can see "decision pipeline
    // ran with zero candidates" for one user-rule. Useful when testing
    // the wiring on a low-traffic dev account.
  }

  const agg = aggregateCooldownDecisions(decisions);

  // The line shape mirrors the existing parity log:
  //   `[digest] brief lead parity user=X rule=Y winner_match=true ...`
  // We use `[digest] cooldown_decision` as the prefix so a single
  // `grep -E "cooldown_decision"` filters the whole stream.
  const line =
    `[digest] cooldown_decision user=${userId} ` +
    `rule=${ruleId} ` +
    `slot=${slot} ` +
    `total=${agg.total} ` +
    `allow=${agg.allow} ` +
    `suppress=${agg.suppress} ` +
    `would_have_dropped=${agg.suppress} ` + // alias: U6 replay names the metric this
    `classification_missing=${agg.classificationMissing} ` +
    `by_reason=${renderSortedKv(agg.byReason)} ` +
    `by_type=${renderSortedKv(agg.byType)}`;

  // Promote to warn ONLY when the stub classifier fell back to the
  // conservative default for at least one cluster. That's a signal the
  // Sprint 3 taxonomy needs to learn a new pattern, and it's the kind
  // of thing Sentry should surface β€” the rest of the cooldown stream is
  // observability noise that only matters in aggregate.
  if (agg.classificationMissing > 0) {
    warn(line);
  } else {
    log(line);
  }
  return line;
}