@autter/otlp-ingester 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,79 @@
1
+ import type { RuntimeOccurrence } from "./types.js";
2
+ /**
3
+ * Suppression rules (1.9.0, EI-38): occurrences the user chose not to keep,
4
+ * set per repository in Autter and delivered with the key (the validator's
5
+ * `suppression` answer, cached with the key for 60 s), or on a static key
6
+ * entry for self-hosted ingesters.
7
+ *
8
+ * A rule is a list of filters that must ALL match (AND) and an action:
9
+ *
10
+ * { "id": "rsr_1", "action": "drop",
11
+ * "filters": [{ "key": "errorType", "op": "equals", "value": "AbortError" }] }
12
+ * { "id": "rsr_2", "action": "sample", "sampleRate": 0.1,
13
+ * "filters": [{ "key": "message", "op": "matches", "value": "ResizeObserver loop*" }] }
14
+ *
15
+ * Keys: errorType, message, service, environment, route, release, source
16
+ * (`browser` or `server`). Operators, all case-insensitive: `equals`,
17
+ * `contains`, `starts_with`, `matches` (a glob over the whole value where
18
+ * `*` is any run of characters; matched segment by segment, so time is
19
+ * linear in the value and no user pattern can stall ingest). A missing
20
+ * value (no route, no release) is the empty string.
21
+ *
22
+ * Rules are tried in order and the first that matches decides: `drop`
23
+ * leaves the occurrence out; `sample` keeps `sampleRate` (0 < rate < 1) of
24
+ * them, chosen by the occurrence id, so a retried batch keeps the same
25
+ * ones, and never samples out a `fatal` occurrence. Suppressed occurrences
26
+ * are not stored or forwarded and are counted in `runtime_ingest_drops`
27
+ * (reason `suppressed`). Request and error-rate rollups are not affected:
28
+ * they count requests, not the occurrences kept.
29
+ *
30
+ * Parsing never throws: an invalid rule (unknown key or operator, empty or
31
+ * oversized value, a sample rate outside (0, 1)) is left out on its own.
32
+ */
33
+ export declare const SUPPRESSION_PROPERTIES: readonly ["errorType", "message", "service", "environment", "route", "release", "source"];
34
+ export type SuppressionProperty = (typeof SUPPRESSION_PROPERTIES)[number];
35
+ export declare const SUPPRESSION_OPERATORS: readonly ["equals", "contains", "starts_with", "matches"];
36
+ export type SuppressionOperator = (typeof SUPPRESSION_OPERATORS)[number];
37
+ export type SuppressionAction = "drop" | "sample";
38
+ export interface SuppressionFilter {
39
+ key: SuppressionProperty;
40
+ op: SuppressionOperator;
41
+ /** Lower-cased at parse time: every comparison is case-insensitive. */
42
+ value: string;
43
+ }
44
+ export interface SuppressionRule {
45
+ id: string;
46
+ filters: SuppressionFilter[];
47
+ action: SuppressionAction;
48
+ /** `sample` only: the share kept, 0 < rate < 1. */
49
+ sampleRate?: number;
50
+ }
51
+ /** Longest filter value accepted; longer ones make the rule invalid. */
52
+ export declare const MAX_SUPPRESSION_VALUE_LENGTH = 500;
53
+ /** One rule, or null when any part of it is invalid (a rule is never half-applied). */
54
+ export declare function parseSuppressionRule(raw: unknown): SuppressionRule | null;
55
+ /** The valid rules of a validator answer or static key entry, in order; [] for anything else. */
56
+ export declare function parseSuppressionRules(raw: unknown): SuppressionRule[];
57
+ /**
58
+ * `*`-glob over the whole text: literal segments must appear in order, the
59
+ * first anchored at the start unless the pattern starts with `*`, the last
60
+ * at the end unless it ends with `*`. Each segment is found leftmost with
61
+ * indexOf, which is always a valid placement for star-only globs, so there
62
+ * is no backtracking. Both sides are expected lower-cased.
63
+ */
64
+ export declare function globMatch(pattern: string, text: string): boolean;
65
+ /** The first rule whose filters all match, or null. */
66
+ export declare function matchingRule(rules: readonly SuppressionRule[], o: RuntimeOccurrence): SuppressionRule | null;
67
+ /**
68
+ * A stable number in [0, 1) for an occurrence: its id is a content-derived
69
+ * hex digest (occurrenceIdFor), so the same occurrence in a retried batch
70
+ * lands in the same place. Ids that are not hex fall back to FNV-1a.
71
+ */
72
+ export declare function sampleUnit(occurrenceId: string): number;
73
+ export interface SuppressionOutcome<T> {
74
+ kept: T[];
75
+ /** Occurrences left out (dropped or sampled out). */
76
+ suppressed: number;
77
+ }
78
+ /** Apply the tenant's rules; order of the kept occurrences is preserved. */
79
+ export declare function applySuppression<T extends RuntimeOccurrence>(occurrences: T[], rules: readonly SuppressionRule[] | undefined): SuppressionOutcome<T>;
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Suppression rules (1.9.0, EI-38): occurrences the user chose not to keep,
3
+ * set per repository in Autter and delivered with the key (the validator's
4
+ * `suppression` answer, cached with the key for 60 s), or on a static key
5
+ * entry for self-hosted ingesters.
6
+ *
7
+ * A rule is a list of filters that must ALL match (AND) and an action:
8
+ *
9
+ * { "id": "rsr_1", "action": "drop",
10
+ * "filters": [{ "key": "errorType", "op": "equals", "value": "AbortError" }] }
11
+ * { "id": "rsr_2", "action": "sample", "sampleRate": 0.1,
12
+ * "filters": [{ "key": "message", "op": "matches", "value": "ResizeObserver loop*" }] }
13
+ *
14
+ * Keys: errorType, message, service, environment, route, release, source
15
+ * (`browser` or `server`). Operators, all case-insensitive: `equals`,
16
+ * `contains`, `starts_with`, `matches` (a glob over the whole value where
17
+ * `*` is any run of characters; matched segment by segment, so time is
18
+ * linear in the value and no user pattern can stall ingest). A missing
19
+ * value (no route, no release) is the empty string.
20
+ *
21
+ * Rules are tried in order and the first that matches decides: `drop`
22
+ * leaves the occurrence out; `sample` keeps `sampleRate` (0 < rate < 1) of
23
+ * them, chosen by the occurrence id, so a retried batch keeps the same
24
+ * ones, and never samples out a `fatal` occurrence. Suppressed occurrences
25
+ * are not stored or forwarded and are counted in `runtime_ingest_drops`
26
+ * (reason `suppressed`). Request and error-rate rollups are not affected:
27
+ * they count requests, not the occurrences kept.
28
+ *
29
+ * Parsing never throws: an invalid rule (unknown key or operator, empty or
30
+ * oversized value, a sample rate outside (0, 1)) is left out on its own.
31
+ */
32
+ export const SUPPRESSION_PROPERTIES = ["errorType", "message", "service", "environment", "route", "release", "source"];
33
+ export const SUPPRESSION_OPERATORS = ["equals", "contains", "starts_with", "matches"];
34
+ /** Longest filter value accepted; longer ones make the rule invalid. */
35
+ export const MAX_SUPPRESSION_VALUE_LENGTH = 500;
36
+ const PROPERTIES = new Set(SUPPRESSION_PROPERTIES);
37
+ const OPERATORS = new Set(SUPPRESSION_OPERATORS);
38
+ function parseFilter(raw) {
39
+ if (!raw || typeof raw !== "object")
40
+ return null;
41
+ const { key, op, value } = raw;
42
+ if (typeof key !== "string" || !PROPERTIES.has(key))
43
+ return null;
44
+ if (typeof op !== "string" || !OPERATORS.has(op))
45
+ return null;
46
+ if (typeof value !== "string" || !value || value.length > MAX_SUPPRESSION_VALUE_LENGTH)
47
+ return null;
48
+ return { key: key, op: op, value: value.toLowerCase() };
49
+ }
50
+ /** One rule, or null when any part of it is invalid (a rule is never half-applied). */
51
+ export function parseSuppressionRule(raw) {
52
+ if (!raw || typeof raw !== "object")
53
+ return null;
54
+ const { id, filters, action, sampleRate } = raw;
55
+ if (typeof id !== "string" || !id || id.length > 200)
56
+ return null;
57
+ if (action !== "drop" && action !== "sample")
58
+ return null;
59
+ if (!Array.isArray(filters) || filters.length === 0)
60
+ return null;
61
+ const parsed = [];
62
+ for (const item of filters) {
63
+ const filter = parseFilter(item);
64
+ if (!filter)
65
+ return null;
66
+ parsed.push(filter);
67
+ }
68
+ if (action === "sample") {
69
+ if (typeof sampleRate !== "number" || !Number.isFinite(sampleRate) || sampleRate <= 0 || sampleRate >= 1)
70
+ return null;
71
+ return { id, filters: parsed, action, sampleRate };
72
+ }
73
+ return { id, filters: parsed, action };
74
+ }
75
+ /** The valid rules of a validator answer or static key entry, in order; [] for anything else. */
76
+ export function parseSuppressionRules(raw) {
77
+ if (!Array.isArray(raw))
78
+ return [];
79
+ const rules = [];
80
+ for (const item of raw) {
81
+ try {
82
+ const rule = parseSuppressionRule(item);
83
+ if (rule)
84
+ rules.push(rule);
85
+ }
86
+ catch {
87
+ // A hostile getter or Proxy: leave the rule out.
88
+ }
89
+ }
90
+ return rules;
91
+ }
92
+ /**
93
+ * `*`-glob over the whole text: literal segments must appear in order, the
94
+ * first anchored at the start unless the pattern starts with `*`, the last
95
+ * at the end unless it ends with `*`. Each segment is found leftmost with
96
+ * indexOf, which is always a valid placement for star-only globs, so there
97
+ * is no backtracking. Both sides are expected lower-cased.
98
+ */
99
+ export function globMatch(pattern, text) {
100
+ const segments = pattern.split("*");
101
+ if (segments.length === 1)
102
+ return pattern === text;
103
+ const first = segments[0];
104
+ const last = segments[segments.length - 1];
105
+ if (!text.startsWith(first))
106
+ return false;
107
+ let at = first.length;
108
+ for (let i = 1; i < segments.length - 1; i++) {
109
+ const segment = segments[i];
110
+ if (!segment)
111
+ continue;
112
+ const found = text.indexOf(segment, at);
113
+ if (found === -1)
114
+ return false;
115
+ at = found + segment.length;
116
+ }
117
+ return text.length - last.length >= at && text.endsWith(last);
118
+ }
119
+ function propertyOf(o, key) {
120
+ switch (key) {
121
+ case "errorType":
122
+ return o.errorType ?? "";
123
+ case "message":
124
+ return o.message ?? "";
125
+ case "service":
126
+ return o.service ?? "";
127
+ case "environment":
128
+ return o.environment ?? "";
129
+ case "route":
130
+ return o.route ?? "";
131
+ case "release":
132
+ return o.release ?? "";
133
+ case "source":
134
+ return o.source ?? "";
135
+ }
136
+ }
137
+ function filterMatches(filter, o) {
138
+ const value = propertyOf(o, filter.key).toLowerCase();
139
+ switch (filter.op) {
140
+ case "equals":
141
+ return value === filter.value;
142
+ case "contains":
143
+ return value.includes(filter.value);
144
+ case "starts_with":
145
+ return value.startsWith(filter.value);
146
+ case "matches":
147
+ return globMatch(filter.value, value);
148
+ }
149
+ }
150
+ /** The first rule whose filters all match, or null. */
151
+ export function matchingRule(rules, o) {
152
+ for (const rule of rules) {
153
+ if (rule.filters.every((filter) => filterMatches(filter, o)))
154
+ return rule;
155
+ }
156
+ return null;
157
+ }
158
+ /**
159
+ * A stable number in [0, 1) for an occurrence: its id is a content-derived
160
+ * hex digest (occurrenceIdFor), so the same occurrence in a retried batch
161
+ * lands in the same place. Ids that are not hex fall back to FNV-1a.
162
+ */
163
+ export function sampleUnit(occurrenceId) {
164
+ const head = occurrenceId.slice(0, 8);
165
+ if (/^[0-9a-f]{8}$/i.test(head))
166
+ return Number.parseInt(head, 16) / 0x1_0000_0000;
167
+ let hash = 0x811c9dc5;
168
+ for (let i = 0; i < occurrenceId.length; i++) {
169
+ hash ^= occurrenceId.charCodeAt(i);
170
+ hash = Math.imul(hash, 0x01000193) >>> 0;
171
+ }
172
+ return hash / 0x1_0000_0000;
173
+ }
174
+ const NEVER_SAMPLED = new Set(["fatal"]);
175
+ /** Apply the tenant's rules; order of the kept occurrences is preserved. */
176
+ export function applySuppression(occurrences, rules) {
177
+ if (!rules?.length || occurrences.length === 0)
178
+ return { kept: occurrences, suppressed: 0 };
179
+ const kept = [];
180
+ let suppressed = 0;
181
+ for (const o of occurrences) {
182
+ let rule = null;
183
+ try {
184
+ rule = matchingRule(rules, o);
185
+ }
186
+ catch {
187
+ // Never fail ingest on a rule: keep the occurrence.
188
+ }
189
+ if (!rule) {
190
+ kept.push(o);
191
+ }
192
+ else if (rule.action === "drop") {
193
+ suppressed += 1;
194
+ }
195
+ else if (NEVER_SAMPLED.has(o.severity) || sampleUnit(o.occurrenceId) < (rule.sampleRate ?? 1)) {
196
+ kept.push(o);
197
+ }
198
+ else {
199
+ suppressed += 1;
200
+ }
201
+ }
202
+ return { kept, suppressed };
203
+ }
package/dist/types.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Breadcrumb } from "./breadcrumbs.js";
2
+ import type { SuppressionRule } from "./suppression.js";
2
3
  export type RuntimeSource = "browser" | "server";
3
4
  /**
4
5
  * Signal severity. Errors AND warnings/info land in the same occurrences
@@ -131,6 +132,13 @@ export interface RuntimeOccurrence extends RuntimeOccurrenceInput {
131
132
  topFrames: string[];
132
133
  /** topFrames[0] — single-column GROUP BY for "where did this come from". */
133
134
  firstFrame: string;
135
+ /**
136
+ * Loop collapse (1.9.0, loop-collapse.ts): the minute's collapsed sample
137
+ * stands for itself plus this many identical occurrences whose payloads
138
+ * were not stored. Stored as `collapsed_count`, sent to the sink as
139
+ * `count` (1 + collapsedCount). Absent (0) on every other occurrence.
140
+ */
141
+ collapsedCount?: number;
134
142
  }
135
143
  export interface RuntimeSpanRow {
136
144
  service: string;
@@ -212,4 +220,10 @@ export interface IngestContext {
212
220
  scope: IngestKeyScope;
213
221
  /** client keys only: exact origins allowed to send (empty = any). */
214
222
  allowedOrigins: string[];
223
+ /**
224
+ * Suppression rules set by the user for this repository (1.9.0): from the
225
+ * key validator's `suppression` answer (cached with the key, 60 s) or a
226
+ * static key's `suppression` entry. Absent or empty: nothing suppressed.
227
+ */
228
+ suppression?: SuppressionRule[];
215
229
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autter/otlp-ingester",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "Self-hostable OTLP + browser-error ingest service for Autter Runtime: normalises telemetry into a per-repo ClickHouse data model",
5
5
  "license": "MIT",
6
6
  "type": "module",