@statoss/sdk 0.3.0 → 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.
@@ -0,0 +1,219 @@
1
+ import { isLevelName, isSentryLevel, LEVEL_RULE } from "./levels.js";
2
+ import { isObject, normalize, safely, safeString } from "./utils.js";
3
+ export const LOG_LEVELS = [
4
+ "trace",
5
+ "debug",
6
+ "info",
7
+ "warn",
8
+ "error",
9
+ "fatal",
10
+ ];
11
+ /** What intake keeps of a line (docs/logs.md, In), in UTF-8 bytes: a body, and an attribute's value. */
12
+ export const MAX_BODY_BYTES = 64 * 1024;
13
+ export const MAX_ATTRIBUTE_BYTES = 8 * 1024;
14
+ /** A cut body's size in bytes before the cut, as intake records it. */
15
+ export const TRUNCATED = "statoss.truncated";
16
+ /** Each level's severity number: the start of its OpenTelemetry range. */
17
+ export const LOG_SEVERITY = {
18
+ trace: 1,
19
+ debug: 5,
20
+ info: 9,
21
+ warn: 13,
22
+ error: 17,
23
+ fatal: 21,
24
+ };
25
+ export function isLogLevel(value) {
26
+ return (typeof value === "string" &&
27
+ LOG_LEVELS.includes(value));
28
+ }
29
+ /** The level a range (a schema's `like`) or a Sentry level is logged at. */
30
+ export function logLevelOf(range) {
31
+ if (range === "warning")
32
+ return "warn";
33
+ return isLogLevel(range) ? range : undefined;
34
+ }
35
+ /** The range a severity number falls in. */
36
+ export function rangeOfSeverity(severity) {
37
+ if (severity >= 21)
38
+ return "fatal";
39
+ if (severity >= 17)
40
+ return "error";
41
+ if (severity >= 13)
42
+ return "warn";
43
+ if (severity >= 9)
44
+ return "info";
45
+ if (severity >= 5)
46
+ return "debug";
47
+ return "trace";
48
+ }
49
+ /**
50
+ * A message with parameters: logger.info(logger.fmt`Paid ${amount} for ${order}`).
51
+ * The line's body is the text; the template and each value go along as
52
+ * the attributes sentry.message.template and sentry.message.parameter.<n>.
53
+ */
54
+ export function fmt(strings, ...values) {
55
+ const parts = Array.from(strings, (s, i) => s ?? strings.raw[i]);
56
+ let text = parts[0];
57
+ for (let i = 1; i < parts.length; i++)
58
+ text += `${bodyOf(values[i - 1])}${parts[i]}`;
59
+ return Object.freeze({
60
+ text,
61
+ template: parts.map((s) => s.replace(/%/g, "%%")).join("%s"),
62
+ values: Object.freeze([...values]),
63
+ toString: () => text,
64
+ });
65
+ }
66
+ export function isLogMessage(value) {
67
+ return (isObject(value) &&
68
+ typeof value.text === "string" &&
69
+ typeof value.template === "string" &&
70
+ Array.isArray(value.values));
71
+ }
72
+ /** An attribute's value as Sentry types it; undefined and null are left out. */
73
+ export function serializeAttribute(value) {
74
+ if (value === undefined || value === null)
75
+ return undefined;
76
+ if (typeof value === "string")
77
+ return { value: cutBytes(value, MAX_ATTRIBUTE_BYTES), type: "string" };
78
+ if (typeof value === "boolean")
79
+ return { value, type: "boolean" };
80
+ if (typeof value === "number") {
81
+ if (!Number.isFinite(value))
82
+ return { value: safeString(value), type: "string" };
83
+ return Number.isInteger(value)
84
+ ? { value, type: "integer" }
85
+ : { value, type: "double" };
86
+ }
87
+ if (typeof value === "bigint") {
88
+ const n = Number(value);
89
+ return Number.isSafeInteger(n)
90
+ ? { value: n, type: "integer" }
91
+ : { value: value.toString(), type: "string" };
92
+ }
93
+ const text = value instanceof String
94
+ ? String(value)
95
+ : (safely(() => JSON.stringify(normalize(value, 4, 1024, 100))) ??
96
+ safeString(value));
97
+ return { value: cutBytes(text, MAX_ATTRIBUTE_BYTES), type: "string" };
98
+ }
99
+ /** UTF-8 bytes of one UTF-16 unit at i, and how many units it takes. */
100
+ function utf8At(s, i) {
101
+ const c = s.charCodeAt(i);
102
+ if (c < 0x80)
103
+ return [1, 1];
104
+ if (c < 0x800)
105
+ return [2, 1];
106
+ if (c >= 0xd800 &&
107
+ c <= 0xdbff &&
108
+ i + 1 < s.length &&
109
+ (s.charCodeAt(i + 1) & 0xfc00) === 0xdc00)
110
+ return [4, 2];
111
+ // A lone surrogate is written as U+FFFD, three bytes.
112
+ return [3, 1];
113
+ }
114
+ /** A string's length in UTF-8 bytes. */
115
+ export function utf8Length(s) {
116
+ let n = 0;
117
+ for (let i = 0; i < s.length;) {
118
+ const [bytes, units] = utf8At(s, i);
119
+ n += bytes;
120
+ i += units;
121
+ }
122
+ return n;
123
+ }
124
+ /**
125
+ * A string cut to at most `max` UTF-8 bytes, the cut marked "...", as
126
+ * intake's cut writes it (intake/normalize.ts), never inside a character.
127
+ */
128
+ export function cutBytes(s, max) {
129
+ if (s.length * 3 <= max || utf8Length(s) <= max)
130
+ return s;
131
+ let n = 0;
132
+ let i = 0;
133
+ while (i < s.length) {
134
+ const [bytes, units] = utf8At(s, i);
135
+ if (n + bytes > max - 3)
136
+ break;
137
+ n += bytes;
138
+ i += units;
139
+ }
140
+ // As intake: a lone high surrogate does not end a cut.
141
+ if (i > 0 && /[\ud800-\udbff]/.test(s[i - 1]))
142
+ i--;
143
+ return `${s.slice(0, i)}...`;
144
+ }
145
+ /**
146
+ * A body past 64 KiB is cut there, with its size in bytes as
147
+ * statoss.truncated, as intake cuts it and records it: one huge line must
148
+ * not take the batch it is sent in down with it.
149
+ */
150
+ export function cutBody(body, attributes) {
151
+ if (body.length * 3 <= MAX_BODY_BYTES)
152
+ return body;
153
+ const size = utf8Length(body);
154
+ if (size <= MAX_BODY_BYTES)
155
+ return body;
156
+ attributes[TRUNCATED] = { value: size, type: "integer" };
157
+ return cutBytes(body, MAX_BODY_BYTES);
158
+ }
159
+ /** The message, or a value in it, as text: objects as JSON. */
160
+ export function bodyOf(message) {
161
+ if (typeof message === "string")
162
+ return message;
163
+ if (isLogMessage(message))
164
+ return message.text;
165
+ if (message instanceof String)
166
+ return String(message);
167
+ if (isObject(message) && !(message instanceof Error)) {
168
+ const json = safely(() => JSON.stringify(normalize(message, 4)));
169
+ if (json !== undefined)
170
+ return json;
171
+ }
172
+ return safeString(message);
173
+ }
174
+ /**
175
+ * The level a line is logged at. One of the six (or a Sentry level) is
176
+ * itself; an app's own level is its range's, with its severity and its
177
+ * name for statoss.level; a name nobody declared ranks as error, as an
178
+ * event's does. Anything else is logged as info.
179
+ */
180
+ export function chooseLogLevel(level, own, logger) {
181
+ if (isLogLevel(level))
182
+ return { level, severity: LOG_SEVERITY[level] };
183
+ if (isSentryLevel(level)) {
184
+ const as = logLevelOf(level) ?? "error";
185
+ return { level: as, severity: LOG_SEVERITY[as] };
186
+ }
187
+ if (isLevelName(level)) {
188
+ const found = own[level];
189
+ if (found)
190
+ return { ...found, custom: level };
191
+ logger.warn(`Level "${level}" is not in the schema or \`levels\`, so the line is logged as error.`);
192
+ return { level: "error", severity: LOG_SEVERITY.error, custom: level };
193
+ }
194
+ logger.warn(`Logged "${safeString(level)}" as info: use trace, debug, info, warn, error, fatal or a level name (${LEVEL_RULE}).`);
195
+ return { level: "info", severity: LOG_SEVERITY.info };
196
+ }
197
+ /** The ranges of init's `levels`: a Sentry level each. */
198
+ export function ownLevelsFromSentry(levels) {
199
+ const out = {};
200
+ for (const [name, sentAs] of Object.entries(levels)) {
201
+ const level = logLevelOf(sentAs) ?? "error";
202
+ out[name] = { level, severity: LOG_SEVERITY[level] };
203
+ }
204
+ return out;
205
+ }
206
+ /** A logger that hands each line to `capture`. */
207
+ export function makeAppLogger(capture) {
208
+ const at = (level) => (message, attributes) => capture(level, message, attributes);
209
+ return Object.freeze({
210
+ trace: at("trace"),
211
+ debug: at("debug"),
212
+ info: at("info"),
213
+ warn: at("warn"),
214
+ error: at("error"),
215
+ fatal: at("fatal"),
216
+ log: (level, message, attributes) => capture(level, message, attributes),
217
+ fmt,
218
+ });
219
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Logger } from "./logger.js";
2
+ import { type AppLogger, type OwnLogLevel } from "./logs.js";
2
3
  import type { CaptureContext, Event, Level, SentryLevel } from "./types.js";
3
4
  /** The ranges a level ranks in: Sentry's five and OpenTelemetry's trace. */
4
5
  export declare const LIKES: readonly ["fatal", "error", "warning", "info", "debug", "trace"];
@@ -190,7 +191,18 @@ export interface Handle<S = SchemaSpec> {
190
191
  captureMessage(message: string, levelOrContext?: LevelOf<S> | SchemaContext<S>): string;
191
192
  /** Sends an event you built in Sentry's schema. Returns the event id. */
192
193
  captureEvent(event: Event, context?: SchemaContext<S>): string;
194
+ /**
195
+ * Log lines, sent when init has `logs: true`; `log` takes the schema's
196
+ * own levels.
197
+ */
198
+ logger: AppLogger<S>;
193
199
  }
200
+ /**
201
+ * How a line at each level of init's `schema` is logged: at its range's
202
+ * level (trace stays trace), with its severity when it gives one inside
203
+ * that range. Names readSchema refuses are left out.
204
+ */
205
+ export declare function schemaLogLevels(schema: unknown): Record<string, OwnLogLevel>;
194
206
  /**
195
207
  * The Sentry level each level of init's `schema` is sent as. Built-in
196
208
  * levels are left out; names the SDK cannot send are logged in debug mode.
@@ -1,4 +1,5 @@
1
1
  import { CATEGORY_RULE, isCategoryName, isLevelName, isSentryLevel, LEVEL_RULE, } from "./levels.js";
2
+ import { LOG_SEVERITY, logLevelOf, rangeOfSeverity, } from "./logs.js";
2
3
  import { isObject, safeString } from "./utils.js";
3
4
  // The server's own lists (src/lib/app-schema.ts, src/lib/schema-colors.ts,
4
5
  // src/db/schema.ts), kept equal by a test.
@@ -74,6 +75,44 @@ function sentAs(name, value, logger) {
74
75
  return "info";
75
76
  return "debug";
76
77
  }
78
+ /**
79
+ * How a line at each level of init's `schema` is logged: at its range's
80
+ * level (trace stays trace), with its severity when it gives one inside
81
+ * that range. Names readSchema refuses are left out.
82
+ */
83
+ export function schemaLogLevels(schema) {
84
+ const out = {};
85
+ if (!isObject(schema) || Array.isArray(schema))
86
+ return out;
87
+ const { levels } = schema;
88
+ if (!isObject(levels) || Array.isArray(levels))
89
+ return out;
90
+ for (const [name, value] of Object.entries(levels)) {
91
+ if (isSentryLevel(name) || !isLevelName(name))
92
+ continue;
93
+ const settings = isObject(value) ? value : {};
94
+ const like = typeof value === "string" ? value : settings.like;
95
+ const given = settings.severity;
96
+ const severity = typeof given === "number" &&
97
+ Number.isInteger(given) &&
98
+ given >= 1 &&
99
+ given <= 24
100
+ ? given
101
+ : undefined;
102
+ let level = "error";
103
+ if (typeof like === "string" && LIKES.includes(like))
104
+ level = logLevelOf(like) ?? "error";
105
+ else if (like === undefined && severity !== undefined)
106
+ level = rangeOfSeverity(severity);
107
+ out[name] = {
108
+ level,
109
+ severity: severity !== undefined && rangeOfSeverity(severity) === level
110
+ ? severity
111
+ : LOG_SEVERITY[level],
112
+ };
113
+ }
114
+ return out;
115
+ }
77
116
  /**
78
117
  * The Sentry level each level of init's `schema` is sent as. Built-in
79
118
  * levels are left out; names the SDK cannot send are logged in debug mode.
@@ -2,6 +2,8 @@ import { type Envelope } from "./envelope.js";
2
2
  import type { Logger } from "./logger.js";
3
3
  import type { Transport } from "./types.js";
4
4
  export declare const QUEUE_LIMIT = 30;
5
+ /** Of the queue, what log envelopes may take, so a flood of lines never pushes out an error. */
6
+ export declare const LOG_QUEUE_LIMIT = 10;
5
7
  export declare const CONCURRENCY = 5;
6
8
  /** The default transport: fetch, called on globalThis (Workers refuse any other this). */
7
9
  export declare function fetchTransport(browserLike: boolean): Transport;
@@ -16,12 +18,15 @@ export interface SenderOptions {
16
18
  }
17
19
  /**
18
20
  * Sends envelopes in order of arrival, a few at a time, keeping at most 30
19
- * waiting or in flight. Whatever cannot be sent is counted for client reports.
21
+ * waiting or in flight, of them at most 10 of log lines. Whatever cannot
22
+ * be sent is counted for client reports.
20
23
  */
21
24
  export declare class Sender {
22
25
  private readonly options;
23
26
  private readonly queue;
24
27
  private active;
28
+ /** Log envelopes waiting or in flight. */
29
+ private logs;
25
30
  private limits;
26
31
  private waiters;
27
32
  private keepaliveBytes;
@@ -1,7 +1,9 @@
1
- import { itemCategory, serializeEnvelope } from "./envelope.js";
1
+ import { isLogEnvelope, itemCategory, itemQuantity, serializeEnvelope, } from "./envelope.js";
2
2
  import { isRateLimited, updateRateLimits, } from "./ratelimit.js";
3
3
  import { safely, unref } from "./utils.js";
4
4
  export const QUEUE_LIMIT = 30;
5
+ /** Of the queue, what log envelopes may take, so a flood of lines never pushes out an error. */
6
+ export const LOG_QUEUE_LIMIT = 10;
5
7
  export const CONCURRENCY = 5;
6
8
  const REQUEST_TIMEOUT_MS = 30000;
7
9
  // Browsers refuse keepalive requests past 64 KiB in flight in total.
@@ -57,13 +59,16 @@ function header(response, name) {
57
59
  }
58
60
  /**
59
61
  * Sends envelopes in order of arrival, a few at a time, keeping at most 30
60
- * waiting or in flight. Whatever cannot be sent is counted for client reports.
62
+ * waiting or in flight, of them at most 10 of log lines. Whatever cannot
63
+ * be sent is counted for client reports.
61
64
  */
62
65
  export class Sender {
63
66
  constructor(options) {
64
67
  this.options = options;
65
68
  this.queue = [];
66
69
  this.active = 0;
70
+ /** Log envelopes waiting or in flight. */
71
+ this.logs = 0;
67
72
  this.limits = {};
68
73
  this.waiters = [];
69
74
  this.keepaliveBytes = 0;
@@ -73,11 +78,15 @@ export class Sender {
73
78
  const allowed = this.withoutLimited(envelope);
74
79
  if (!allowed)
75
80
  return;
76
- if (this.queue.length + this.active >= QUEUE_LIMIT) {
81
+ const log = isLogEnvelope(allowed);
82
+ if (this.queue.length + this.active >= QUEUE_LIMIT ||
83
+ (log && this.logs >= LOG_QUEUE_LIMIT)) {
77
84
  this.options.logger.warn("Dropped an envelope: the send queue is full.");
78
85
  this.lose(allowed, "queue_overflow");
79
86
  return;
80
87
  }
88
+ if (log)
89
+ this.logs++;
81
90
  this.queue.push(allowed);
82
91
  this.pump();
83
92
  }
@@ -126,7 +135,7 @@ export class Sender {
126
135
  if (!isRateLimited(this.limits, category, now))
127
136
  return true;
128
137
  if (category !== "internal")
129
- this.options.recordLoss("ratelimit_backoff", category);
138
+ this.options.recordLoss("ratelimit_backoff", category, itemQuantity(item));
130
139
  return false;
131
140
  });
132
141
  if (items.length < envelope[1].length) {
@@ -136,13 +145,20 @@ export class Sender {
136
145
  }
137
146
  pump() {
138
147
  while (this.active < CONCURRENCY && this.queue.length > 0) {
148
+ const waited = this.queue.shift();
149
+ const log = isLogEnvelope(waited);
139
150
  // A 429 may have come in while it waited.
140
- const next = this.withoutLimited(this.queue.shift());
141
- if (!next)
151
+ const next = this.withoutLimited(waited);
152
+ if (!next) {
153
+ if (log)
154
+ this.logs--;
142
155
  continue;
156
+ }
143
157
  this.active++;
144
158
  const done = () => {
145
159
  this.active--;
160
+ if (log)
161
+ this.logs--;
146
162
  this.pump();
147
163
  };
148
164
  void this.deliver(next).then(done, done);
@@ -207,7 +223,7 @@ export class Sender {
207
223
  const category = itemCategory(item);
208
224
  // A lost client report is not reported again.
209
225
  if (category !== "internal")
210
- this.options.recordLoss(reason, category);
226
+ this.options.recordLoss(reason, category, itemQuantity(item));
211
227
  }
212
228
  }
213
229
  }
@@ -1,3 +1,4 @@
1
+ import type { Log } from "./logs.js";
1
2
  /** The five levels Sentry knows. */
2
3
  export type SentryLevel = "fatal" | "error" | "warning" | "info" | "debug";
3
4
  /**
@@ -174,7 +175,10 @@ export interface InitOptions {
174
175
  beforeBreadcrumb?: (breadcrumb: Breadcrumb, hint?: BreadcrumbHint) => Breadcrumb | null;
175
176
  /** Defaults to 100, which is also the most kept. */
176
177
  maxBreadcrumbs?: number;
177
- /** When true, browsers ask the server to record the sender's IP address. Defaults to false. */
178
+ /**
179
+ * When true, browsers ask the server to record the sender's IP address,
180
+ * and log lines carry the scope's user. Defaults to false.
181
+ */
178
182
  sendDefaultPii?: boolean;
179
183
  /** Tags on every event. */
180
184
  tags?: Record<string, Primitive>;
@@ -187,4 +191,8 @@ export interface InitOptions {
187
191
  integrations?: IntegrationToggles;
188
192
  /** Your own level names and the Sentry level each is sent as, such as { "payment-failed": "error" }. */
189
193
  levels?: Record<string, SentryLevel>;
194
+ /** Sends the lines of `logger`, as Sentry's enableLogs does. Defaults to false. */
195
+ logs?: boolean;
196
+ /** Change or drop (return null) a log line before it is sent. */
197
+ beforeSendLog?: (log: Log) => Log | null;
190
198
  }
@@ -1,3 +1,3 @@
1
1
  export declare const SDK_NAME = "statoss.javascript";
2
- export declare const SDK_VERSION = "0.3.0";
2
+ export declare const SDK_VERSION = "0.4.0";
3
3
  export declare const SDK_PACKAGE = "npm:@statoss/sdk";
@@ -1,4 +1,4 @@
1
1
  export const SDK_NAME = "statoss.javascript";
2
2
  // Kept equal to package.json by a test.
3
- export const SDK_VERSION = "0.3.0";
3
+ export const SDK_VERSION = "0.4.0";
4
4
  export const SDK_PACKAGE = "npm:@statoss/sdk";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@statoss/sdk",
3
- "version": "0.3.0",
4
- "description": "Error tracking for StatOSS: browsers, Node, Bun, Deno, Cloudflare Workers and Vercel Edge. Speaks Sentry's protocol.",
3
+ "version": "0.4.0",
4
+ "description": "Error tracking and logs for StatOSS: browsers, Node, Bun, Deno, Cloudflare Workers and Vercel Edge. Speaks Sentry's protocol.",
5
5
  "license": "MIT",
6
6
  "author": "StatOSS",
7
7
  "homepage": "https://statoss.com",
@@ -14,6 +14,7 @@
14
14
  "statoss",
15
15
  "errors",
16
16
  "error-tracking",
17
+ "logs",
17
18
  "sentry",
18
19
  "monitoring"
19
20
  ],
@@ -38,6 +39,7 @@
38
39
  "files": [
39
40
  "dist",
40
41
  "README.md",
42
+ "CHANGELOG.md",
41
43
  "LICENSE"
42
44
  ],
43
45
  "engines": {