@statoss/sdk 0.2.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,41 @@
1
+ # Changelog
2
+
3
+ ## 0.4.0 (unreleased)
4
+
5
+ - Logs: `init({ logs: true })` sends the lines of `logger` as Sentry's log
6
+ items. `logger.trace`, `debug`, `info`, `warn`, `error` and `fatal` take a
7
+ message and attributes; `logger.fmt` sends a template and its values.
8
+ - `logger.log(level, message, attributes)` takes the app's own levels, typed
9
+ by the schema on the logger `init` returns. An own level is sent at its
10
+ range's level with its severity and the attribute `statoss.level`.
11
+ - `beforeSendLog` changes or drops a line.
12
+ - Lines go out 100 at a time or 5 seconds after the first, on `flush` and
13
+ `close`, and in Node, Bun and Deno when the process runs out of work.
14
+ - Envelopes of log lines take at most 10 of the 30 places in the send
15
+ queue, so errors still go during a flood of lines.
16
+ - A body past 64 KiB is cut there, with its size in bytes before the cut as
17
+ `statoss.truncated`, and an attribute's value at 8 KiB, in UTF-8 bytes,
18
+ as StatOSS's intake cuts and records them.
19
+ - Every line carries a `trace_id` of 32 hex digits, one per client for its
20
+ life, since Sentry's Relay drops a line without one.
21
+
22
+ ## 0.3.0 (8 Oct 2026)
23
+
24
+ - Levels, categories and fields in a typed schema: `defineSchema`, and
25
+ capture functions from `init` that take only the schema's names.
26
+ - A level name is at most 32 characters, as the server keeps it (it was 64);
27
+ a longer name is sent as plain `error`.
28
+
29
+ ## 0.2.0 (8 Oct 2026)
30
+
31
+ - Debug IDs: events name the debug IDs of the files their frames ran in,
32
+ for source maps uploaded with sentry-cli or `@statoss/cli`.
33
+
34
+ ## 0.1.1 (7 Oct 2026)
35
+
36
+ - The first release published from GitHub Actions with trusted publishing.
37
+
38
+ ## 0.1.0 (7 Oct 2026)
39
+
40
+ - Error tracking for browsers, Node, Bun, Deno, Cloudflare Workers and
41
+ Vercel Edge, in Sentry's protocol.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # @statoss/sdk
2
2
 
3
- Error tracking for [StatOSS](https://statoss.com). One package for browsers,
4
- Node, Bun, Deno, Cloudflare Workers and Vercel Edge, with TypeScript types
5
- included and no dependencies.
3
+ Error tracking and logs for [StatOSS](https://statoss.com). One package for
4
+ browsers, Node, Bun, Deno, Cloudflare Workers and Vercel Edge, with
5
+ TypeScript types included and no dependencies.
6
6
 
7
7
  The SDK speaks Sentry's protocol (envelopes, event schema, rate limits,
8
8
  client reports), so it can also send to Sentry, and Sentry's own SDKs can
@@ -269,20 +269,88 @@ Adding `--release <release>` to the upload is optional. Deploy the bundles as
269
269
  `inject` left them: the SDK reads their debug ids and sends them with each
270
270
  event. Frames are then shown as source and grouped by their source names.
271
271
 
272
- ## Custom levels and categories
272
+ ## Your own levels, categories and fields
273
273
 
274
274
  Sentry has five levels: `fatal`, `error`, `warning`, `info` and `debug`.
275
- StatOSS lets you use your own words as well, and adds a category. Both are
276
- sent as tags next to the nearest Sentry level, so Sentry and any Sentry-based
277
- tool still read the event normally.
275
+ StatOSS adds levels of your own, categories and fields. They travel as tags
276
+ next to the nearest Sentry level, so Sentry and any Sentry-based tool still
277
+ read the event normally.
278
+
279
+ Declare them with `defineSchema` and give the schema to `init`. The capture
280
+ functions `init` returns take only the schema's names, so a wrong one fails
281
+ to compile.
278
282
 
279
283
  ```ts
280
- StatOSS.init({
281
- dsn: "...",
284
+ import * as StatOSS from "@statoss/sdk";
285
+
286
+ export const schema = StatOSS.defineSchema({
282
287
  levels: {
283
- "payment-failed": "error",
288
+ "payment-failed": { like: "error", label: "Payment failed" },
284
289
  "slow-query": "warning",
285
290
  },
291
+ categories: {
292
+ billing: { label: "Billing", color: "plum" },
293
+ auth: {},
294
+ },
295
+ fields: {
296
+ route: { label: "Route", filterable: true },
297
+ duration_ms: { type: "number", unit: "ms", label: "Duration" },
298
+ },
299
+ });
300
+
301
+ const statoss = StatOSS.init({ dsn: process.env.STATOSS_DSN, schema });
302
+
303
+ statoss.captureException(error, {
304
+ level: "payment-failed",
305
+ category: "billing",
306
+ fields: { route: "/pay" },
307
+ });
308
+ // level "error", tags { "statoss.level": "payment-failed",
309
+ // "statoss.category": "billing", route: "/pay" }
310
+
311
+ statoss.captureMessage("Search took 2.4 s", {
312
+ level: "slow-query",
313
+ fields: { duration_ms: 2400 },
314
+ });
315
+
316
+ // @ts-expect-error: not a level of this schema
317
+ statoss.captureException(error, { level: "payment-faild" });
318
+ ```
319
+
320
+ - A level ranks like `fatal`, `error`, `warning`, `info`, `debug` or
321
+ `trace`: give that alone, or `like` with an optional `severity` (1 to 24,
322
+ inside its range), `label` and `color`. It is sent as the nearest Sentry
323
+ level (`trace` as `debug`; `error` when none is given).
324
+ - A category takes an optional `label`, `color`, `owner` (a team member's
325
+ email) and `hidden`.
326
+ - A field is a tag key, with an optional `label`, `type` (`text` or
327
+ `number`), `unit` and `filterable`. Field values are strings, numbers or
328
+ booleans.
329
+ - Categories and fields can also be lists of names.
330
+ - `rules` set a level or a category for events that match, such as
331
+ `{ subject: "logger", value: "payments", category: "billing" }`. They name
332
+ the schema's own levels and categories.
333
+ - Colours: `crimson`, `red`, `orange`, `amber`, `yellow`, `green`, `aqua`,
334
+ `teal`, `blue`, `violet`, `indigo`, `magenta`, `plum` and `grey`.
335
+ - Names: a level is 1 to 32 characters and a category 1 to 64, of lowercase
336
+ letters, digits, dots, dashes and underscores. A field is any tag key not
337
+ starting `statoss.`.
338
+
339
+ `defineSchema` refuses a wrong name, range, severity, colour or setting at
340
+ compile time (TypeScript 5.0 or later). It returns the schema as given:
341
+ plain data in the shape StatOSS's schema API takes. A function of yours
342
+ that hands a schema to `init` types it as
343
+ `<const S extends SchemaSpec & CheckSchema<S>>(schema: S)`.
344
+
345
+ ### Without a schema
346
+
347
+ The module-level functions take any level or category name, and `levels` in
348
+ `init` maps your names to Sentry levels:
349
+
350
+ ```ts
351
+ StatOSS.init({
352
+ dsn: "...",
353
+ levels: { "payment-failed": "error", "slow-query": "warning" },
286
354
  });
287
355
 
288
356
  StatOSS.captureMessage("Card declined", "payment-failed");
@@ -295,13 +363,63 @@ StatOSS.captureException(error, {
295
363
  // level "warning", tags { "statoss.level": "slow-query", "statoss.category": "db.postgres" }
296
364
  ```
297
365
 
298
- - `level` takes a Sentry level or any name of your own. A name that is not in
299
- `levels` is sent as `error`.
300
- - `category` sets the `statoss.category` tag.
301
- - Names are 1 to 64 characters of lowercase letters, digits, dots, dashes and
302
- underscores. Others are ignored; with `debug: true` the SDK logs each one.
366
+ - A name in neither `levels` nor the schema is sent as `error`. Given both,
367
+ `levels` wins.
368
+ - Names the SDK cannot send are ignored; with `debug: true` it logs each
369
+ one.
303
370
  - StatOSS filters and groups on these tags like any other.
304
371
 
372
+ ## Logs
373
+
374
+ With `logs: true`, `logger`'s lines are sent as Sentry's log items, which
375
+ StatOSS's Logs area searches. Without it they are dropped.
376
+
377
+ ```ts
378
+ import * as StatOSS from "@statoss/sdk";
379
+
380
+ StatOSS.init({ dsn: "...", logs: true });
381
+
382
+ StatOSS.logger.info("Order paid", { orderId: 7, amount: 12.5 });
383
+ StatOSS.logger.warn(StatOSS.logger.fmt`Search for ${term} took ${ms} ms`);
384
+ StatOSS.logger.log("error", "Card declined", { orderId: 7 });
385
+ ```
386
+
387
+ - `trace`, `debug`, `info`, `warn`, `error` and `fatal` each take a message
388
+ and attributes. Strings, numbers and booleans are sent typed; anything
389
+ else as JSON.
390
+ - `logger.fmt` fills the values in and sends the template and each value
391
+ as `sentry.message.template` and `sentry.message.parameter.<n>`.
392
+ - `logger.log(level, message, attributes)` takes a level by name: one of the
393
+ six, a Sentry level (`warning`), or one of your own. Your own level is
394
+ sent at its range's level (`trace` stays `trace`), with its severity if
395
+ the schema gives one, and its name as the attribute `statoss.level`. A
396
+ name in neither the schema nor `levels` is sent as `error`.
397
+ - Each line carries `sentry.environment`, `sentry.release`,
398
+ `sentry.sdk.name`, `sentry.sdk.version` and `server.address`, and with
399
+ `sendDefaultPii: true` the scope's `user.id`, `user.email` and
400
+ `user.name`. A line's own attributes win.
401
+ - Each line carries a `trace_id` of 32 hex digits, one for the life of the
402
+ client, since Sentry's Relay drops a line without one.
403
+ - `beforeSendLog(log)` changes a line or drops it (return `null`).
404
+ - A body past 64 KiB is cut there, with its size in bytes as
405
+ `statoss.truncated`, and an attribute's value at 8 KiB, in UTF-8 bytes,
406
+ as StatOSS's intake cuts and records them.
407
+ - Lines go out 100 at a time, or 5 seconds after the first waits; `flush`
408
+ and `close` send what waits, and in Node, Bun and Deno so does the
409
+ process running out of work.
410
+
411
+ With a schema, the logger `init` returns takes only its levels:
412
+
413
+ ```ts
414
+ const statoss = StatOSS.init({ dsn: "...", schema, logs: true });
415
+
416
+ statoss.logger.log("payment-failed", "Card declined", { orderId: 7 });
417
+ // level "error", severity 17, attributes { "statoss.level": "payment-failed", orderId: 7 }
418
+
419
+ // @ts-expect-error: not a level of the schema
420
+ statoss.logger.log("payment-faild", "Card declined");
421
+ ```
422
+
305
423
  ## What is captured
306
424
 
307
425
  | Integration | Runtimes | What it does |
@@ -356,7 +474,11 @@ await StatOSS.withScope(async (scope) => {
356
474
  ## API
357
475
 
358
476
  ```ts
359
- init(options): void
477
+ init(options): handle // handle.captureException, captureMessage, captureEvent, logger
478
+ defineSchema(schema): schema // the schema as given, its names as types
479
+ logger.trace, debug, info, warn, error, fatal(message, attributes?)
480
+ logger.log(level, message, attributes?) // a level by name, your own too
481
+ logger.fmt`text ${value}` // a message with its template and values
360
482
  captureException(error, context?): string // returns the event id
361
483
  captureMessage(message, levelOrContext?): string
362
484
  captureEvent(event, context?): string
@@ -374,8 +496,9 @@ scope.setUser, setTag, setTags, setExtra, setExtras, setContext, setLevel,
374
496
  scope.captureException, captureMessage, captureEvent // with this scope
375
497
  ```
376
498
 
377
- `context` is `{ level, tags, extra, contexts, user, fingerprint, category }`.
378
- It wins over the scope for that one event.
499
+ `context` is `{ level, tags, extra, contexts, user, fingerprint, category }`,
500
+ and on the handle `init` returns also `fields`. It wins over the scope for
501
+ that one event.
379
502
 
380
503
  ### Options
381
504
 
@@ -389,10 +512,13 @@ It wins over the scope for that one event.
389
512
  | `beforeSend` | | `(event, hint) => event \| null`, may be async |
390
513
  | `beforeBreadcrumb` | | `(breadcrumb, hint) => breadcrumb \| null` |
391
514
  | `maxBreadcrumbs` | `100` | 100 at most |
392
- | `sendDefaultPii` | `false` | Record the sender's IP address |
515
+ | `sendDefaultPii` | `false` | Record the sender's IP address; the user on log lines |
393
516
  | `tags` | | Tags on every event |
394
517
  | `initialScope` | | `{ tags, extra, contexts, user, level, fingerprint }` |
518
+ | `schema` | | From `defineSchema`: your levels, categories, fields |
395
519
  | `levels` | | Your level names and the Sentry level each is sent as |
520
+ | `logs` | `false` | Send `logger`'s lines |
521
+ | `beforeSendLog` | | `(log) => log \| null` |
396
522
  | `integrations` | all on | See above |
397
523
  | `enabled` | `true` | |
398
524
  | `debug` | `false` | Log what the SDK does |
@@ -400,14 +526,16 @@ It wins over the scope for that one event.
400
526
 
401
527
  ## Sending to Sentry
402
528
 
403
- Use a Sentry DSN instead. Events, client reports, rate limits (including a
404
- used-up quota) and the `X-Sentry-Auth` header all follow Sentry's protocol.
405
- Custom levels arrive as Sentry levels with the `statoss.level` tag.
529
+ Use a Sentry DSN instead. Events, log lines, client reports, rate limits
530
+ (including a used-up quota) and the `X-Sentry-Auth` header all follow
531
+ Sentry's protocol. Custom levels arrive as Sentry levels with the
532
+ `statoss.level` tag, on events and log lines alike.
406
533
 
407
534
  ## Network behaviour
408
535
 
409
536
  Envelopes go to `https://<host>/api/<app number>/envelope/`, at most 5 at a
410
- time and 30 queued or in flight; more are dropped and counted. When the server answers 429, the SDK holds back
537
+ time and 30 queued or in flight, of them at most 10 of log lines; more are
538
+ dropped and counted. When the server answers 429, the SDK holds back
411
539
  the limited categories for as long as `Retry-After` or `X-Sentry-Rate-Limits`
412
540
  says. Events it could not send (queue full, rate limited, network error,
413
541
  sampled out, dropped by `beforeSend`) are counted and reported as Sentry
@@ -2,6 +2,10 @@ import { type Logger } from "./logger.js";
2
2
  import { type RuntimeInfo } from "./runtime.js";
3
3
  import { type Scope } from "./scope.js";
4
4
  import type { Breadcrumb, BreadcrumbHint, CaptureContext, Event, InitOptions } from "./types.js";
5
+ /** init's options as the client reads them: init may pass a schema. */
6
+ export type ClientOptions = InitOptions & {
7
+ schema?: unknown;
8
+ };
5
9
  export declare class Client {
6
10
  readonly logger: Logger;
7
11
  readonly runtime: RuntimeInfo;
@@ -12,6 +16,8 @@ export declare class Client {
12
16
  private readonly endpoint?;
13
17
  private readonly sender?;
14
18
  private readonly levels;
19
+ private readonly logLevels;
20
+ private readonly logsOn;
15
21
  private readonly sampleRate;
16
22
  private readonly maxBreadcrumbs;
17
23
  private readonly discarded;
@@ -20,9 +26,22 @@ export declare class Client {
20
26
  private reportTimer?;
21
27
  private previousSignature?;
22
28
  private closed;
23
- constructor(options: InitOptions);
29
+ private logBuffer;
30
+ private logChars;
31
+ private logTimer?;
32
+ private saidLogsOff;
33
+ /** The trace id of every log line: one per client, as Sentry's SDKs do without tracing. */
34
+ private logTraceId;
35
+ constructor(options: ClientOptions);
24
36
  /** Installs the integrations that apply to this runtime. */
25
37
  setup(): void;
38
+ /**
39
+ * Keeps a log line for the next envelope of lines. The level is one of
40
+ * the six, a Sentry level, or the app's own (sent at its range's level
41
+ * with statoss.level); attributes go along typed, as Sentry's logger
42
+ * sends them.
43
+ */
44
+ captureLog(level: unknown, message: unknown, attributes: unknown, scope: Scope): void;
26
45
  captureException(error: unknown, context: CaptureContext | undefined, scope: Scope): string;
27
46
  captureMessage(message: unknown, context: CaptureContext | undefined, scope: Scope): string;
28
47
  captureEvent(event: Event, context: CaptureContext | undefined, scope: Scope): string;
@@ -37,8 +56,16 @@ export declare class Client {
37
56
  flush(timeoutMs?: number): Promise<boolean>;
38
57
  /** Flushes, then removes the integrations. Nothing is captured afterwards. */
39
58
  close(timeoutMs?: number): Promise<boolean>;
40
- /** Removes the integrations at once, without waiting. */
59
+ /** Removes the integrations at once, without waiting; log lines kept go out. */
41
60
  teardown(): void;
61
+ private keepLog;
62
+ /** Sends the log lines kept, as one envelope. */
63
+ private sendLogs;
64
+ /**
65
+ * In Node, Bun and Deno: sends the lines kept when the process runs out
66
+ * of work, since the timer that would send them does not keep it alive.
67
+ */
68
+ private sendLogsBeforeExit;
42
69
  private flushReportsWhenHidden;
43
70
  private capture;
44
71
  /** Merges scope, context and options into the event, in Sentry's field order. */
@@ -13,13 +13,20 @@ const fetch_js_1 = require("./integrations/fetch.js");
13
13
  const handlers_js_1 = require("./integrations/handlers.js");
14
14
  const levels_js_1 = require("./levels.js");
15
15
  const logger_js_1 = require("./logger.js");
16
+ const logs_js_1 = require("./logs.js");
16
17
  const runtime_js_1 = require("./runtime.js");
18
+ const schema_js_1 = require("./schema.js");
17
19
  const scope_js_1 = require("./scope.js");
18
20
  const transport_js_1 = require("./transport.js");
19
21
  const utils_js_1 = require("./utils.js");
20
22
  const version_js_1 = require("./version.js");
21
23
  const REPORT_INTERVAL_MS = 30000;
22
24
  const DEFAULT_FLUSH_MS = 2000;
25
+ // Log lines go out 100 at a time, or 5 seconds after the first waits, as
26
+ // Sentry's logger sends them, and well under an envelope item's 1 MiB.
27
+ const LOG_BATCH = 100;
28
+ const LOG_WAIT_MS = 5000;
29
+ const LOG_BATCH_CHARS = 800000;
23
30
  // The server drops event items over 1 MiB.
24
31
  const MAX_EVENT_CHARS = 1000000;
25
32
  const MAX_TAG_VALUE = 200;
@@ -54,6 +61,67 @@ function cleanTags(tags) {
54
61
  }
55
62
  return out;
56
63
  }
64
+ /**
65
+ * A line as a log item holds it, made valid again after beforeSendLog: one
66
+ * of the six levels, a severity inside its range, typed attributes.
67
+ */
68
+ function serializeLog(log, traceId) {
69
+ const level = (0, logs_js_1.isLogLevel)(log.level)
70
+ ? log.level
71
+ : ((0, logs_js_1.logLevelOf)((0, utils_js_1.safeString)(log.level)) ?? "info");
72
+ const n = log.severityNumber;
73
+ const severity = typeof n === "number" &&
74
+ Number.isInteger(n) &&
75
+ n >= logs_js_1.LOG_SEVERITY[level] &&
76
+ n <= logs_js_1.LOG_SEVERITY[level] + 3
77
+ ? n
78
+ : logs_js_1.LOG_SEVERITY[level];
79
+ const attributes = {};
80
+ if ((0, utils_js_1.isObject)(log.attributes)) {
81
+ for (const [key, value] of Object.entries(log.attributes)) {
82
+ const typed = (0, logs_js_1.serializeAttribute)(value);
83
+ if (key !== "" && typed)
84
+ attributes[key] = typed;
85
+ }
86
+ }
87
+ const body = (0, logs_js_1.cutBody)((0, logs_js_1.bodyOf)(log.message), attributes);
88
+ return {
89
+ timestamp: typeof log.timestamp === "number" && Number.isFinite(log.timestamp)
90
+ ? log.timestamp
91
+ : (0, utils_js_1.now)(),
92
+ level,
93
+ body,
94
+ trace_id: traceId,
95
+ severity_number: severity,
96
+ attributes,
97
+ };
98
+ }
99
+ /** A capture's fields, as tags. Keys starting statoss. are not fields. */
100
+ function fieldTags(fields, logger) {
101
+ const out = {};
102
+ if (fields === undefined || fields === null)
103
+ return out;
104
+ if (!(0, utils_js_1.isObject)(fields) || Array.isArray(fields)) {
105
+ logger.warn('Ignored `fields`: it should be an object such as { route: "/pay" }.');
106
+ return out;
107
+ }
108
+ for (const [key, value] of Object.entries(fields)) {
109
+ if (value === undefined || value === null)
110
+ continue;
111
+ if (key === "" || key.startsWith("statoss.")) {
112
+ logger.warn(`Ignored field "${key}": fields are not empty and do not start statoss.`);
113
+ }
114
+ else if (typeof value === "string" ||
115
+ typeof value === "number" ||
116
+ typeof value === "boolean") {
117
+ out[key] = value;
118
+ }
119
+ else {
120
+ logger.warn(`Ignored field "${key}": its value should be a string, number or boolean.`);
121
+ }
122
+ }
123
+ return out;
124
+ }
57
125
  function cleanContexts(contexts) {
58
126
  const out = {};
59
127
  for (const [name, value] of Object.entries(contexts)) {
@@ -100,10 +168,20 @@ class Client {
100
168
  this.pending = new Set();
101
169
  this.teardowns = [];
102
170
  this.closed = false;
171
+ this.logBuffer = [];
172
+ this.logChars = 0;
173
+ this.saidLogsOff = false;
103
174
  this.options = options;
104
175
  this.logger = (0, logger_js_1.makeLogger)(options.debug === true);
105
176
  this.runtime = (0, runtime_js_1.describeRuntime)();
106
- this.levels = (0, levels_js_1.readLevels)(options.levels, this.logger);
177
+ // What `levels` says wins over the schema.
178
+ const given = (0, levels_js_1.readLevels)(options.levels, this.logger);
179
+ this.levels = { ...(0, schema_js_1.readSchema)(options.schema, this.logger), ...given };
180
+ this.logLevels = {
181
+ ...(0, schema_js_1.schemaLogLevels)(options.schema),
182
+ ...(0, logs_js_1.ownLevelsFromSentry)(given),
183
+ };
184
+ this.logsOn = options.logs === true;
107
185
  const rate = options.sampleRate;
108
186
  if (rate !== undefined &&
109
187
  !(typeof rate === "number" && rate >= 0 && rate <= 1)) {
@@ -182,8 +260,90 @@ class Client {
182
260
  this.integrations.push("Dedupe");
183
261
  if (browser)
184
262
  this.flushReportsWhenHidden();
263
+ if (this.logsOn)
264
+ this.sendLogsBeforeExit();
185
265
  this.logger.log(`Integrations: ${this.integrations.join(", ") || "none"}.`);
186
266
  }
267
+ /**
268
+ * Keeps a log line for the next envelope of lines. The level is one of
269
+ * the six, a Sentry level, or the app's own (sent at its range's level
270
+ * with statoss.level); attributes go along typed, as Sentry's logger
271
+ * sends them.
272
+ */
273
+ captureLog(level, message, attributes, scope) {
274
+ if (!this.enabled || this.closed)
275
+ return;
276
+ if (!this.logsOn) {
277
+ if (!this.saidLogsOff) {
278
+ this.saidLogsOff = true;
279
+ this.logger.log("Log lines are not sent: init has no logs: true.");
280
+ }
281
+ return;
282
+ }
283
+ try {
284
+ const choice = (0, logs_js_1.chooseLogLevel)(level, this.logLevels, this.logger);
285
+ const attrs = {};
286
+ if ((0, utils_js_1.isObject)(attributes) && !Array.isArray(attributes)) {
287
+ for (const [key, value] of Object.entries(attributes))
288
+ if (key !== "")
289
+ attrs[key] = value;
290
+ }
291
+ else if (attributes !== undefined && attributes !== null) {
292
+ this.logger.warn("Ignored a log line's attributes: they should be an object such as { orderId: 7 }.");
293
+ }
294
+ // What the line says wins over what the SDK adds.
295
+ const add = (key, value) => {
296
+ if (value !== undefined && value !== null && !(key in attrs))
297
+ attrs[key] = value;
298
+ };
299
+ add("sentry.environment", this.options.environment ?? "production");
300
+ add("sentry.release", this.options.release);
301
+ add("sentry.sdk.name", version_js_1.SDK_NAME);
302
+ add("sentry.sdk.version", version_js_1.SDK_VERSION);
303
+ add("server.address", this.options.serverName ?? this.runtime.serverName);
304
+ // The user goes on a line only with sendDefaultPii.
305
+ const user = scope._user;
306
+ if (user && this.options.sendDefaultPii) {
307
+ add("user.id", user.id);
308
+ add("user.email", user.email);
309
+ add("user.name", user.username);
310
+ }
311
+ if ((0, logs_js_1.isLogMessage)(message) && message.values.length > 0) {
312
+ attrs["sentry.message.template"] = message.template;
313
+ message.values.forEach((value, i) => {
314
+ attrs[`sentry.message.parameter.${i}`] = value;
315
+ });
316
+ }
317
+ if (choice.custom)
318
+ attrs[levels_js_1.LEVEL_TAG] = choice.custom;
319
+ let log = {
320
+ level: choice.level,
321
+ message: (0, logs_js_1.bodyOf)(message),
322
+ attributes: attrs,
323
+ severityNumber: choice.severity,
324
+ timestamp: (0, utils_js_1.now)(),
325
+ };
326
+ if (this.options.beforeSendLog) {
327
+ try {
328
+ log = this.options.beforeSendLog(log);
329
+ }
330
+ catch (error) {
331
+ this.logger.error("beforeSendLog threw, so the line was dropped:", error);
332
+ this.recordLoss("internal_sdk_error", "log_item");
333
+ return;
334
+ }
335
+ if (!(0, utils_js_1.isObject)(log)) {
336
+ this.recordLoss("before_send", "log_item");
337
+ return;
338
+ }
339
+ }
340
+ this.logTraceId ?? (this.logTraceId = (0, utils_js_1.uuid4)());
341
+ this.keepLog(serializeLog(log, this.logTraceId));
342
+ }
343
+ catch (error) {
344
+ this.logger.error("Could not capture a log line:", error);
345
+ }
346
+ }
187
347
  captureException(error, context, scope) {
188
348
  return this.capture(() => (0, event_js_1.eventFromUnknown)(error, { type: "generic", handled: true }), context, scope, { originalException: error });
189
349
  }
@@ -266,6 +426,7 @@ class Client {
266
426
  async flush(timeoutMs = DEFAULT_FLUSH_MS) {
267
427
  if (!this.sender)
268
428
  return true;
429
+ this.sendLogs();
269
430
  const deadline = Date.now() + timeoutMs;
270
431
  const left = () => Math.max(0, deadline - Date.now());
271
432
  const processed = this.pending.size
@@ -286,18 +447,63 @@ class Client {
286
447
  this.teardown();
287
448
  return this.flush(timeoutMs);
288
449
  }
289
- /** Removes the integrations at once, without waiting. */
450
+ /** Removes the integrations at once, without waiting; log lines kept go out. */
290
451
  teardown() {
291
452
  for (const teardown of this.teardowns.splice(0))
292
453
  (0, utils_js_1.safely)(teardown);
454
+ this.sendLogs();
455
+ }
456
+ keepLog(log) {
457
+ const size = (0, utils_js_1.safely)(() => JSON.stringify(log).length, 0) ?? 0;
458
+ if (this.logBuffer.length > 0 && this.logChars + size > LOG_BATCH_CHARS)
459
+ this.sendLogs();
460
+ this.logBuffer.push(log);
461
+ this.logChars += size;
462
+ if (this.logBuffer.length >= LOG_BATCH) {
463
+ this.sendLogs();
464
+ }
465
+ else if (this.logTimer === undefined) {
466
+ this.logTimer = (0, utils_js_1.safely)(() => setTimeout(() => {
467
+ this.logTimer = undefined;
468
+ this.sendLogs();
469
+ }, LOG_WAIT_MS));
470
+ (0, utils_js_1.unref)(this.logTimer);
471
+ }
472
+ }
473
+ /** Sends the log lines kept, as one envelope. */
474
+ sendLogs() {
475
+ if (this.logTimer !== undefined) {
476
+ clearTimeout(this.logTimer);
477
+ this.logTimer = undefined;
478
+ }
479
+ if (this.logBuffer.length === 0 || !this.sender || !this.dsn)
480
+ return;
481
+ const logs = this.logBuffer;
482
+ this.logBuffer = [];
483
+ this.logChars = 0;
484
+ this.sender.send((0, envelope_js_1.logEnvelope)(logs, this.dsn));
485
+ }
486
+ /**
487
+ * In Node, Bun and Deno: sends the lines kept when the process runs out
488
+ * of work, since the timer that would send them does not keep it alive.
489
+ */
490
+ sendLogsBeforeExit() {
491
+ const proc = globalThis.process;
492
+ if (!proc || typeof proc.on !== "function")
493
+ return;
494
+ const onBeforeExit = () => (0, utils_js_1.safely)(() => this.sendLogs());
495
+ (0, utils_js_1.safely)(() => proc.on("beforeExit", onBeforeExit));
496
+ this.teardowns.push(() => proc.removeListener?.("beforeExit", onBeforeExit));
293
497
  }
294
498
  flushReportsWhenHidden() {
295
499
  const doc = globalThis.document;
296
500
  if (!doc || typeof doc.addEventListener !== "function")
297
501
  return;
298
502
  const onChange = () => {
299
- if (doc.visibilityState === "hidden")
300
- (0, utils_js_1.safely)(() => this.sendClientReport());
503
+ if (doc.visibilityState !== "hidden")
504
+ return;
505
+ (0, utils_js_1.safely)(() => this.sendLogs());
506
+ (0, utils_js_1.safely)(() => this.sendClientReport());
301
507
  };
302
508
  doc.addEventListener("visibilitychange", onChange);
303
509
  this.teardowns.push(() => doc.removeEventListener?.("visibilitychange", onChange));
@@ -329,6 +535,7 @@ class Client {
329
535
  const tags = {
330
536
  ...scope._tags,
331
537
  ...ctx.tags,
538
+ ...fieldTags(ctx.fields, this.logger),
332
539
  ...base.tags,
333
540
  };
334
541
  if (choice.custom)
@@ -1,4 +1,5 @@
1
1
  import { type Dsn } from "./dsn.js";
2
+ import type { SerializedLog } from "./logs.js";
2
3
  import type { Event } from "./types.js";
3
4
  export interface DiscardedEvent {
4
5
  reason: string;
@@ -26,11 +27,29 @@ export type ClientReportItem = [
26
27
  discarded_events: DiscardedEvent[];
27
28
  }
28
29
  ];
29
- export type EnvelopeItem = EventItem | ClientReportItem;
30
+ export declare const LOG_CONTENT_TYPE = "application/vnd.sentry.items.log+json";
31
+ export type LogItem = [
32
+ {
33
+ type: "log";
34
+ item_count: number;
35
+ content_type: typeof LOG_CONTENT_TYPE;
36
+ },
37
+ {
38
+ version: 2;
39
+ items: SerializedLog[];
40
+ }
41
+ ];
42
+ export type EnvelopeItem = EventItem | ClientReportItem | LogItem;
30
43
  export type Envelope = [EnvelopeHeader, EnvelopeItem[]];
31
44
  export declare function eventEnvelope(event: Event, dsn: Dsn): Envelope;
32
45
  export declare function clientReportEnvelope(discarded: DiscardedEvent[], dsn: Dsn, timestamp?: number): Envelope;
46
+ /** Log lines in one envelope, as Sentry's logger sends them. */
47
+ export declare function logEnvelope(logs: SerializedLog[], dsn: Dsn): Envelope;
33
48
  /** Sentry's rate-limit category for an envelope item. */
34
49
  export declare function itemCategory(item: EnvelopeItem): string;
50
+ /** How many things an item holds, as client reports count them. */
51
+ export declare function itemQuantity(item: EnvelopeItem): number;
52
+ /** True for an envelope of log lines. */
53
+ export declare function isLogEnvelope(envelope: Envelope): boolean;
35
54
  /** Byte for byte what Sentry's serializeEnvelope produces for string payloads. */
36
55
  export declare function serializeEnvelope(envelope: Envelope): string;