@realuptime/errors 0.4.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.
package/dist/buffer.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Bounded in-memory event buffer, modelled on apps/agent/buffer.ts: oldest
3
+ * dropped when full, every drop counted, and the count is reported in the
4
+ * next successful batch (`droppedClient` on the wire) so even client-side
5
+ * drops reach the product's visible drop counters. In-memory only; an error
6
+ * SDK that writes to the customer's filesystem is over the line.
7
+ */
8
+ export const BUFFER_MAX = 200;
9
+ export class EventBuffer {
10
+ events = [];
11
+ droppedSinceReport = 0;
12
+ push(event) {
13
+ if (this.events.length >= BUFFER_MAX) {
14
+ this.events.shift();
15
+ this.droppedSinceReport += 1;
16
+ }
17
+ this.events.push(event);
18
+ }
19
+ get length() {
20
+ return this.events.length;
21
+ }
22
+ /** Reads up to `n` events without removing them; `commit(n)` removes them
23
+ * only after the server acknowledged the batch, so a failed delivery
24
+ * retries the same events instead of losing them. */
25
+ peekBatch(n) {
26
+ return this.events.slice(0, n);
27
+ }
28
+ commit(n) {
29
+ this.events.splice(0, n);
30
+ }
31
+ /** The drop count to carry on the NEXT batch. Taken (reset to zero) only
32
+ * when the caller is about to put it on the wire; if that delivery fails
33
+ * the caller must give it back via `restoreDropped`. */
34
+ takeDropped() {
35
+ const n = this.droppedSinceReport;
36
+ this.droppedSinceReport = 0;
37
+ return n;
38
+ }
39
+ restoreDropped(n) {
40
+ this.droppedSinceReport += n;
41
+ }
42
+ }
@@ -0,0 +1,53 @@
1
+ import { type WireEvent } from "./types.ts";
2
+ /**
3
+ * Delivery: batched, buffered, backed off, and incapable of crashing the
4
+ * host app (docs/errors-plan.md, "The SDKs"). Disciplines inherited from
5
+ * apps/agent/api.ts and runtime.ts:
6
+ *
7
+ * - Failures never throw out of the SDK. Everything is caught; the first
8
+ * failure of each kind logs ONCE and further ones are silent, because
9
+ * an error tracker that floods the console is nearly as bad as one that
10
+ * throws.
11
+ * - Transient failures (network, 5xx, unparseable body) back off
12
+ * exponentially and the events stay buffered.
13
+ * - `over_quota` responses stop delivery until the reset instant the
14
+ * server names. Retrying into a hard cap burns the customer's cycles
15
+ * and our ingest budget for nothing; the drop already happened and is
16
+ * already counted server-side.
17
+ * - `key_revoked` (403) disables the SDK for the process lifetime after
18
+ * one log line naming the fix. A revoked key never becomes an event
19
+ * black hole or a retry storm.
20
+ * - `rate_limited` (429) is transient: back off and keep the events.
21
+ */
22
+ export declare const SEND_TIMEOUT_MS = 10000;
23
+ export declare const BACKOFF_START_MS = 5000;
24
+ export declare const BACKOFF_MAX_MS = 300000;
25
+ export interface TransportOptions {
26
+ endpoint: string;
27
+ sdk: string;
28
+ fetchImpl?: typeof fetch;
29
+ now?: () => number;
30
+ log?: (message: string) => void;
31
+ }
32
+ export declare class Transport {
33
+ private readonly options;
34
+ private readonly buffer;
35
+ private readonly fetchImpl;
36
+ private readonly now;
37
+ private readonly log;
38
+ private backoffMs;
39
+ private nextAttemptAt;
40
+ private pausedUntil;
41
+ private disabled;
42
+ private loggedKinds;
43
+ private inFlight;
44
+ constructor(options: TransportOptions);
45
+ enqueue(event: WireEvent): void;
46
+ /** Sends what is due. Serialized: one delivery in flight at a time, so a
47
+ * burst of captures becomes one batch, not a request per event. */
48
+ flush(): Promise<void>;
49
+ private logOnce;
50
+ private deliver;
51
+ private pauseUntil;
52
+ private backOff;
53
+ }
package/dist/client.js ADDED
@@ -0,0 +1,151 @@
1
+ import { EventBuffer } from "./buffer.js";
2
+ import { MAX_EVENTS_PER_BATCH } from "./types.js";
3
+ /**
4
+ * Delivery: batched, buffered, backed off, and incapable of crashing the
5
+ * host app (docs/errors-plan.md, "The SDKs"). Disciplines inherited from
6
+ * apps/agent/api.ts and runtime.ts:
7
+ *
8
+ * - Failures never throw out of the SDK. Everything is caught; the first
9
+ * failure of each kind logs ONCE and further ones are silent, because
10
+ * an error tracker that floods the console is nearly as bad as one that
11
+ * throws.
12
+ * - Transient failures (network, 5xx, unparseable body) back off
13
+ * exponentially and the events stay buffered.
14
+ * - `over_quota` responses stop delivery until the reset instant the
15
+ * server names. Retrying into a hard cap burns the customer's cycles
16
+ * and our ingest budget for nothing; the drop already happened and is
17
+ * already counted server-side.
18
+ * - `key_revoked` (403) disables the SDK for the process lifetime after
19
+ * one log line naming the fix. A revoked key never becomes an event
20
+ * black hole or a retry storm.
21
+ * - `rate_limited` (429) is transient: back off and keep the events.
22
+ */
23
+ export const SEND_TIMEOUT_MS = 10_000;
24
+ export const BACKOFF_START_MS = 5_000;
25
+ export const BACKOFF_MAX_MS = 300_000;
26
+ export class Transport {
27
+ options;
28
+ buffer = new EventBuffer();
29
+ fetchImpl;
30
+ now;
31
+ log;
32
+ backoffMs = 0;
33
+ nextAttemptAt = 0;
34
+ pausedUntil = 0;
35
+ disabled = false;
36
+ loggedKinds = new Set();
37
+ inFlight = null;
38
+ constructor(options) {
39
+ this.options = options;
40
+ this.fetchImpl = options.fetchImpl ?? fetch;
41
+ this.now = options.now ?? Date.now;
42
+ this.log = options.log ?? ((message) => console.error(message));
43
+ }
44
+ enqueue(event) {
45
+ if (this.disabled)
46
+ return;
47
+ this.buffer.push(event);
48
+ void this.flush();
49
+ }
50
+ /** Sends what is due. Serialized: one delivery in flight at a time, so a
51
+ * burst of captures becomes one batch, not a request per event. */
52
+ flush() {
53
+ if (this.inFlight)
54
+ return this.inFlight;
55
+ this.inFlight = this.deliver().finally(() => {
56
+ this.inFlight = null;
57
+ });
58
+ return this.inFlight;
59
+ }
60
+ logOnce(kind, message) {
61
+ if (this.loggedKinds.has(kind))
62
+ return;
63
+ this.loggedKinds.add(kind);
64
+ this.log(`[realuptime-errors] ${message}`);
65
+ }
66
+ async deliver() {
67
+ while (this.buffer.length > 0) {
68
+ const now = this.now();
69
+ if (this.disabled || now < this.nextAttemptAt || now < this.pausedUntil)
70
+ return;
71
+ const events = this.buffer.peekBatch(MAX_EVENTS_PER_BATCH);
72
+ const droppedClient = this.buffer.takeDropped();
73
+ const batch = { sdk: this.options.sdk, droppedClient, events };
74
+ let response;
75
+ try {
76
+ response = await this.fetchImpl(this.options.endpoint, {
77
+ method: "POST",
78
+ headers: { "content-type": "application/json" },
79
+ body: JSON.stringify(batch),
80
+ signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
81
+ });
82
+ }
83
+ catch {
84
+ this.buffer.restoreDropped(droppedClient);
85
+ this.backOff("network", "cannot reach the ingest endpoint; buffering and retrying");
86
+ return;
87
+ }
88
+ if (response.ok) {
89
+ this.buffer.commit(events.length);
90
+ this.backoffMs = 0;
91
+ this.nextAttemptAt = 0;
92
+ let body = null;
93
+ try {
94
+ body = (await response.json());
95
+ }
96
+ catch {
97
+ body = null;
98
+ }
99
+ if (body?.overQuota) {
100
+ this.pauseUntil(body.quota?.resetsAt);
101
+ this.logOnce("over-quota", `monthly event quota reached (${body.quota?.limit ?? "?"}); pausing until ${body.quota?.resetsAt ?? "the window resets"}. Dropped events are counted and shown on your realuptime Errors dashboard.`);
102
+ return;
103
+ }
104
+ continue;
105
+ }
106
+ let refusal = {};
107
+ try {
108
+ refusal = (await response.json());
109
+ }
110
+ catch {
111
+ refusal = {};
112
+ }
113
+ if (response.status === 403 && refusal.reason === "key_revoked") {
114
+ this.disabled = true;
115
+ this.logOnce("revoked", "this project's ingest key was revoked; error reporting is disabled for this process. Rotate the key in realuptime Errors settings and redeploy with the new DSN.");
116
+ return;
117
+ }
118
+ if (response.status === 429 && refusal.reason === "over_quota") {
119
+ // The whole batch was refused at the cap: nothing was written, and
120
+ // holding the events would only re-refuse them all window long.
121
+ this.buffer.commit(events.length);
122
+ this.pauseUntil(refusal.resetsAt);
123
+ this.logOnce("over-quota", `monthly event quota reached; pausing until ${refusal.resetsAt ?? "the window resets"}. Dropped events are counted and shown on your realuptime Errors dashboard.`);
124
+ return;
125
+ }
126
+ if (response.status === 400) {
127
+ // A malformed batch will never become well-formed by retrying; drop
128
+ // it rather than wedging the buffer, and say so once.
129
+ this.buffer.commit(events.length);
130
+ this.buffer.restoreDropped(droppedClient);
131
+ this.logOnce("malformed", `the server refused a batch as malformed: ${refusal.error ?? "no detail"}. This is an SDK bug worth reporting.`);
132
+ continue;
133
+ }
134
+ this.buffer.restoreDropped(droppedClient);
135
+ this.backOff("transient", `ingest endpoint answered ${response.status}; buffering and retrying`);
136
+ return;
137
+ }
138
+ }
139
+ pauseUntil(resetsAt) {
140
+ const parsed = resetsAt ? Date.parse(resetsAt) : Number.NaN;
141
+ // A missing or unparseable reset instant still pauses meaningfully: an
142
+ // hour is long enough to stop the burn, short enough to recover early
143
+ // in a new window.
144
+ this.pausedUntil = Number.isFinite(parsed) ? parsed : this.now() + 60 * 60_000;
145
+ }
146
+ backOff(kind, message) {
147
+ this.backoffMs = this.backoffMs <= 0 ? BACKOFF_START_MS : Math.min(this.backoffMs * 2, BACKOFF_MAX_MS);
148
+ this.nextAttemptAt = this.now() + this.backoffMs;
149
+ this.logOnce(kind, `${message} (backing off).`);
150
+ }
151
+ }
@@ -0,0 +1,48 @@
1
+ import type { WireEvent } from "./types.ts";
2
+ /**
3
+ * Customer-defined ADDITIONAL scrub rules, client side (REA-264). The rules
4
+ * a project's owner set in the realuptime Errors settings are fetched once
5
+ * at init (see index.ts, `fetchScrubRules`) and applied here AFTER the
6
+ * default scrub (scrub.ts), never instead of it. There is nothing in this
7
+ * file that can relax a default: a rule can only add a field name, a key
8
+ * glob, or a value regex that turns more into `[scrubbed]`.
9
+ *
10
+ * DUPLICATED from packages/db/error-scrub-rules.ts, the scrub.ts
11
+ * convention: this package must stay import-free. custom-scrub-vectors.json
12
+ * pins the two (and the Python SDK) to byte-identical output; a semantic
13
+ * change is a three-implementation change.
14
+ *
15
+ * The validator is carried here too, even though rules only ever arrive
16
+ * from our own endpoint, because "only ever" is not a guarantee the host
17
+ * app should depend on: a rule that fails the same safe-subset check the
18
+ * server applies is skipped, so no network response can hand this process
19
+ * a catastrophic-backtracking pattern.
20
+ */
21
+ export type CustomScrubRuleKind = "field_name" | "key_glob" | "value_regex";
22
+ export interface CustomScrubRule {
23
+ kind: CustomScrubRuleKind;
24
+ pattern: string;
25
+ }
26
+ /** Most rules a response may carry; matches the server's per-project cap. */
27
+ export declare const MAX_CUSTOM_SCRUB_RULES = 50;
28
+ export declare function validateCustomScrubRule(input: {
29
+ kind: unknown;
30
+ pattern: unknown;
31
+ }): boolean;
32
+ export interface CompiledCustomScrubRules {
33
+ matchesKey: (name: string) => boolean;
34
+ valueRegexes: RegExp[];
35
+ count: number;
36
+ }
37
+ /** Compiles rules, skipping any that fail validation. Never throws. */
38
+ export declare function compileCustomScrubRules(rules: readonly unknown[]): CompiledCustomScrubRules;
39
+ export declare const NO_CUSTOM_SCRUB_RULES: CompiledCustomScrubRules;
40
+ /** Applies compiled rules to an event that has ALREADY been through
41
+ * scrubEvent. Returns a new object; never mutates the input. */
42
+ export declare function applyCustomScrubRules(event: WireEvent, compiled: CompiledCustomScrubRules): WireEvent;
43
+ /**
44
+ * The rules endpoint for a DSN: the ingest URL with `/ingest/` swapped for
45
+ * `/scrub-rules/`. Returns null for a DSN that is not ingest-shaped, in
46
+ * which case no fetch is attempted and the defaults run alone.
47
+ */
48
+ export declare function scrubRulesUrlFor(dsn: string): string | null;
@@ -0,0 +1,262 @@
1
+ import { SCRUBBED } from "./scrub.js";
2
+ const KINDS = ["field_name", "key_glob", "value_regex"];
3
+ const MAX_PATTERN_LENGTH = 200;
4
+ const MAX_QUANTIFIERS = 10;
5
+ const MAX_REPEAT = 100;
6
+ /** Most rules a response may carry; matches the server's per-project cap. */
7
+ export const MAX_CUSTOM_SCRUB_RULES = 50;
8
+ export function validateCustomScrubRule(input) {
9
+ if (typeof input?.kind !== "string" || !KINDS.includes(input.kind))
10
+ return false;
11
+ if (typeof input.pattern !== "string")
12
+ return false;
13
+ const pattern = input.pattern;
14
+ if (pattern.trim().length === 0 || pattern.length > MAX_PATTERN_LENGTH)
15
+ return false;
16
+ if (/[\r\n]/.test(pattern))
17
+ return false;
18
+ if (input.kind === "field_name" || input.kind === "key_glob") {
19
+ if (pattern !== pattern.trim())
20
+ return false;
21
+ const hasWildcard = /[*?]/.test(pattern);
22
+ return input.kind === "key_glob" ? hasWildcard : !hasWildcard;
23
+ }
24
+ return safeRegex(pattern);
25
+ }
26
+ function safeRegex(pattern) {
27
+ const groupHasQuantifier = [];
28
+ let quantifiers = 0;
29
+ let inClass = false;
30
+ let lastClosedGroupHadQuantifier = false;
31
+ let lastWasQuantifier = false;
32
+ let lastWasAtom = false;
33
+ for (let i = 0; i < pattern.length; i++) {
34
+ const ch = pattern[i];
35
+ if (ch === "\\") {
36
+ const next = pattern[i + 1];
37
+ if (next === undefined)
38
+ return false;
39
+ if (/[1-9]/.test(next) || next === "k")
40
+ return false;
41
+ if (next === "p" || next === "P" || next === "u" || next === "c")
42
+ return false;
43
+ i += 1;
44
+ if (!inClass) {
45
+ lastWasAtom = true;
46
+ lastWasQuantifier = false;
47
+ lastClosedGroupHadQuantifier = false;
48
+ }
49
+ continue;
50
+ }
51
+ if (inClass) {
52
+ if (ch === "]")
53
+ inClass = false;
54
+ continue;
55
+ }
56
+ if (ch === "[") {
57
+ inClass = true;
58
+ if (pattern[i + 1] === "^")
59
+ i += 1;
60
+ if (pattern[i + 1] === "]")
61
+ i += 1;
62
+ lastWasAtom = true;
63
+ lastWasQuantifier = false;
64
+ lastClosedGroupHadQuantifier = false;
65
+ continue;
66
+ }
67
+ if (ch === "(") {
68
+ if (pattern[i + 1] === "?") {
69
+ if (pattern[i + 2] !== ":")
70
+ return false;
71
+ i += 2;
72
+ }
73
+ groupHasQuantifier.push(false);
74
+ lastWasAtom = false;
75
+ lastWasQuantifier = false;
76
+ lastClosedGroupHadQuantifier = false;
77
+ continue;
78
+ }
79
+ if (ch === ")") {
80
+ const had = groupHasQuantifier.pop();
81
+ if (had === undefined)
82
+ return false;
83
+ lastClosedGroupHadQuantifier = had;
84
+ if (had && groupHasQuantifier.length > 0)
85
+ groupHasQuantifier[groupHasQuantifier.length - 1] = true;
86
+ lastWasAtom = true;
87
+ lastWasQuantifier = false;
88
+ continue;
89
+ }
90
+ const isBrace = ch === "{";
91
+ if (ch === "*" || ch === "+" || ch === "?" || isBrace) {
92
+ if (isBrace) {
93
+ const close = pattern.indexOf("}", i);
94
+ const body = close === -1 ? null : pattern.slice(i + 1, close);
95
+ const m = body === null ? null : /^(\d+)(?:,(\d*))?$/.exec(body);
96
+ if (!m) {
97
+ lastWasAtom = true;
98
+ lastWasQuantifier = false;
99
+ lastClosedGroupHadQuantifier = false;
100
+ continue;
101
+ }
102
+ const lo = Number(m[1]);
103
+ const hi = m[2] === undefined ? lo : m[2] === "" ? Number.POSITIVE_INFINITY : Number(m[2]);
104
+ if (lo > MAX_REPEAT || (Number.isFinite(hi) && hi > MAX_REPEAT))
105
+ return false;
106
+ if (Number.isFinite(hi) && hi < lo)
107
+ return false;
108
+ i = close;
109
+ }
110
+ if (ch === "?" && lastWasQuantifier) {
111
+ lastWasQuantifier = false;
112
+ continue;
113
+ }
114
+ if (!lastWasAtom)
115
+ return false;
116
+ if (lastClosedGroupHadQuantifier)
117
+ return false;
118
+ quantifiers += 1;
119
+ if (quantifiers > MAX_QUANTIFIERS)
120
+ return false;
121
+ if (groupHasQuantifier.length > 0)
122
+ groupHasQuantifier[groupHasQuantifier.length - 1] = true;
123
+ lastWasAtom = false;
124
+ lastWasQuantifier = true;
125
+ lastClosedGroupHadQuantifier = false;
126
+ continue;
127
+ }
128
+ if (ch === "|") {
129
+ lastWasAtom = false;
130
+ lastWasQuantifier = false;
131
+ lastClosedGroupHadQuantifier = false;
132
+ continue;
133
+ }
134
+ lastWasAtom = true;
135
+ lastWasQuantifier = false;
136
+ lastClosedGroupHadQuantifier = false;
137
+ }
138
+ if (inClass || groupHasQuantifier.length > 0)
139
+ return false;
140
+ let compiled;
141
+ try {
142
+ compiled = new RegExp(pattern, "g");
143
+ }
144
+ catch {
145
+ return false;
146
+ }
147
+ return !new RegExp(compiled.source).test("");
148
+ }
149
+ function globToRegExp(glob) {
150
+ let out = "^";
151
+ for (const ch of glob) {
152
+ if (ch === "*")
153
+ out += ".*";
154
+ else if (ch === "?")
155
+ out += ".";
156
+ else
157
+ out += ch.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
158
+ }
159
+ return new RegExp(`${out}$`, "i");
160
+ }
161
+ /** Compiles rules, skipping any that fail validation. Never throws. */
162
+ export function compileCustomScrubRules(rules) {
163
+ const names = new Set();
164
+ const globs = [];
165
+ const valueRegexes = [];
166
+ let count = 0;
167
+ for (const raw of rules.slice(0, MAX_CUSTOM_SCRUB_RULES)) {
168
+ const rule = raw;
169
+ if (!rule || typeof rule !== "object" || !validateCustomScrubRule({ kind: rule.kind, pattern: rule.pattern }))
170
+ continue;
171
+ count += 1;
172
+ const pattern = rule.pattern;
173
+ if (rule.kind === "field_name")
174
+ names.add(pattern.toLowerCase());
175
+ else if (rule.kind === "key_glob")
176
+ globs.push(globToRegExp(pattern));
177
+ else
178
+ valueRegexes.push(new RegExp(pattern, "g"));
179
+ }
180
+ return {
181
+ matchesKey: (name) => {
182
+ const lower = name.toLowerCase();
183
+ if (names.has(lower))
184
+ return true;
185
+ for (const glob of globs)
186
+ if (glob.test(lower))
187
+ return true;
188
+ return false;
189
+ },
190
+ valueRegexes,
191
+ count,
192
+ };
193
+ }
194
+ export const NO_CUSTOM_SCRUB_RULES = { matchesKey: () => false, valueRegexes: [], count: 0 };
195
+ function applyValue(value, compiled) {
196
+ let out = value;
197
+ for (const re of compiled.valueRegexes) {
198
+ re.lastIndex = 0;
199
+ out = out.replace(re, SCRUBBED);
200
+ }
201
+ return out;
202
+ }
203
+ /** Applies compiled rules to an event that has ALREADY been through
204
+ * scrubEvent. Returns a new object; never mutates the input. */
205
+ export function applyCustomScrubRules(event, compiled) {
206
+ if (compiled.count === 0)
207
+ return event;
208
+ const value = (input) => applyValue(input, compiled);
209
+ const entry = (name, input) => (compiled.matchesKey(name) ? SCRUBBED : value(input));
210
+ const map = (input, prefix = "") => {
211
+ const out = {};
212
+ for (const [name, v] of Object.entries(input))
213
+ out[name] = typeof v === "string" ? entry(prefix + name, v) : v;
214
+ return out;
215
+ };
216
+ return {
217
+ ...event,
218
+ message: value(event.message),
219
+ request: event.request
220
+ ? {
221
+ ...event.request,
222
+ ...(typeof event.request.path === "string" ? { path: value(event.request.path) } : {}),
223
+ ...(typeof event.request.route === "string" ? { route: value(event.request.route) } : {}),
224
+ ...(event.request.headers ? { headers: map(event.request.headers) } : {}),
225
+ }
226
+ : event.request,
227
+ breadcrumbs: event.breadcrumbs
228
+ ? event.breadcrumbs.map((crumb) => ({
229
+ ...crumb,
230
+ message: value(crumb.message),
231
+ ...(crumb.data ? { data: map(crumb.data) } : {}),
232
+ }))
233
+ : event.breadcrumbs,
234
+ frames: event.frames
235
+ ? event.frames.map((frame) => frame.contextLine != null || frame.preContext?.length || frame.postContext?.length || frame.vars
236
+ ? {
237
+ ...frame,
238
+ ...(frame.contextLine != null ? { contextLine: value(frame.contextLine) } : {}),
239
+ ...(frame.preContext ? { preContext: frame.preContext.map(value) } : {}),
240
+ ...(frame.postContext ? { postContext: frame.postContext.map(value) } : {}),
241
+ ...(frame.vars ? { vars: map(frame.vars) } : {}),
242
+ }
243
+ : frame)
244
+ : event.frames,
245
+ ...(event.user ? { user: map(event.user, "user.") } : {}),
246
+ ...(event.tags ? { tags: map(event.tags) } : {}),
247
+ ...(event.context ? { context: map(event.context) } : {}),
248
+ ...(event.device ? { device: map(event.device) } : {}),
249
+ };
250
+ }
251
+ /**
252
+ * The rules endpoint for a DSN: the ingest URL with `/ingest/` swapped for
253
+ * `/scrub-rules/`. Returns null for a DSN that is not ingest-shaped, in
254
+ * which case no fetch is attempted and the defaults run alone.
255
+ */
256
+ export function scrubRulesUrlFor(dsn) {
257
+ const marker = "/api/errors/v1/ingest/";
258
+ const at = dsn.indexOf(marker);
259
+ if (at === -1)
260
+ return null;
261
+ return `${dsn.slice(0, at)}/api/errors/v1/scrub-rules/${dsn.slice(at + marker.length)}`;
262
+ }