@statoss/sdk 0.2.0 → 0.3.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/README.md CHANGED
@@ -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,11 +363,10 @@ 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
 
305
372
  ## What is captured
@@ -356,7 +423,8 @@ await StatOSS.withScope(async (scope) => {
356
423
  ## API
357
424
 
358
425
  ```ts
359
- init(options): void
426
+ init(options): handle // handle.captureException, captureMessage, captureEvent
427
+ defineSchema(schema): schema // the schema as given, its names as types
360
428
  captureException(error, context?): string // returns the event id
361
429
  captureMessage(message, levelOrContext?): string
362
430
  captureEvent(event, context?): string
@@ -374,8 +442,9 @@ scope.setUser, setTag, setTags, setExtra, setExtras, setContext, setLevel,
374
442
  scope.captureException, captureMessage, captureEvent // with this scope
375
443
  ```
376
444
 
377
- `context` is `{ level, tags, extra, contexts, user, fingerprint, category }`.
378
- It wins over the scope for that one event.
445
+ `context` is `{ level, tags, extra, contexts, user, fingerprint, category }`,
446
+ and on the handle `init` returns also `fields`. It wins over the scope for
447
+ that one event.
379
448
 
380
449
  ### Options
381
450
 
@@ -392,6 +461,7 @@ It wins over the scope for that one event.
392
461
  | `sendDefaultPii` | `false` | Record the sender's IP address |
393
462
  | `tags` | | Tags on every event |
394
463
  | `initialScope` | | `{ tags, extra, contexts, user, level, fingerprint }` |
464
+ | `schema` | | From `defineSchema`: your levels, categories, fields |
395
465
  | `levels` | | Your level names and the Sentry level each is sent as |
396
466
  | `integrations` | all on | See above |
397
467
  | `enabled` | `true` | |
@@ -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;
@@ -20,7 +24,7 @@ export declare class Client {
20
24
  private reportTimer?;
21
25
  private previousSignature?;
22
26
  private closed;
23
- constructor(options: InitOptions);
27
+ constructor(options: ClientOptions);
24
28
  /** Installs the integrations that apply to this runtime. */
25
29
  setup(): void;
26
30
  captureException(error: unknown, context: CaptureContext | undefined, scope: Scope): string;
@@ -14,6 +14,7 @@ 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
16
  const runtime_js_1 = require("./runtime.js");
17
+ const schema_js_1 = require("./schema.js");
17
18
  const scope_js_1 = require("./scope.js");
18
19
  const transport_js_1 = require("./transport.js");
19
20
  const utils_js_1 = require("./utils.js");
@@ -54,6 +55,32 @@ function cleanTags(tags) {
54
55
  }
55
56
  return out;
56
57
  }
58
+ /** A capture's fields, as tags. Keys starting statoss. are not fields. */
59
+ function fieldTags(fields, logger) {
60
+ const out = {};
61
+ if (fields === undefined || fields === null)
62
+ return out;
63
+ if (!(0, utils_js_1.isObject)(fields) || Array.isArray(fields)) {
64
+ logger.warn('Ignored `fields`: it should be an object such as { route: "/pay" }.');
65
+ return out;
66
+ }
67
+ for (const [key, value] of Object.entries(fields)) {
68
+ if (value === undefined || value === null)
69
+ continue;
70
+ if (key === "" || key.startsWith("statoss.")) {
71
+ logger.warn(`Ignored field "${key}": fields are not empty and do not start statoss.`);
72
+ }
73
+ else if (typeof value === "string" ||
74
+ typeof value === "number" ||
75
+ typeof value === "boolean") {
76
+ out[key] = value;
77
+ }
78
+ else {
79
+ logger.warn(`Ignored field "${key}": its value should be a string, number or boolean.`);
80
+ }
81
+ }
82
+ return out;
83
+ }
57
84
  function cleanContexts(contexts) {
58
85
  const out = {};
59
86
  for (const [name, value] of Object.entries(contexts)) {
@@ -103,7 +130,11 @@ class Client {
103
130
  this.options = options;
104
131
  this.logger = (0, logger_js_1.makeLogger)(options.debug === true);
105
132
  this.runtime = (0, runtime_js_1.describeRuntime)();
106
- this.levels = (0, levels_js_1.readLevels)(options.levels, this.logger);
133
+ // What `levels` says wins over the schema.
134
+ this.levels = {
135
+ ...(0, schema_js_1.readSchema)(options.schema, this.logger),
136
+ ...(0, levels_js_1.readLevels)(options.levels, this.logger),
137
+ };
107
138
  const rate = options.sampleRate;
108
139
  if (rate !== undefined &&
109
140
  !(typeof rate === "number" && rate >= 0 && rate <= 1)) {
@@ -329,6 +360,7 @@ class Client {
329
360
  const tags = {
330
361
  ...scope._tags,
331
362
  ...ctx.tags,
363
+ ...fieldTags(ctx.fields, this.logger),
332
364
  ...base.tags,
333
365
  };
334
366
  if (choice.custom)
@@ -1,5 +1,8 @@
1
+ import type { CheckSchema, Handle, SchemaSpec } from "./schema.js";
1
2
  import type { Breadcrumb, CaptureContext, Event, InitOptions, Level, Primitive, User } from "./types.js";
2
3
  export { withScope } from "./hub.js";
4
+ export { defineSchema } from "./schema.js";
5
+ export type { CategoryOf, CategorySettings, CheckSchema, FieldOf, FieldSettings, FieldType, FieldValue, Handle, LevelOf, LevelSettings, Like, RuleMatch, RuleSubject, SchemaColor, SchemaContext, SchemaRule, SchemaSpec, Severity, } from "./schema.js";
3
6
  export type { Scope } from "./scope.js";
4
7
  export { SDK_NAME, SDK_VERSION } from "./version.js";
5
8
  export type { Breadcrumb, BreadcrumbHint, CaptureContext, DebugImage, Event, EventHint, Exception, InitOptions, IntegrationToggles, Level, Mechanism, Primitive, RequestInfo, ScopeData, SdkInfo, SentryLevel, StackFrame, Transport, TransportRequest, TransportResponse, User, } from "./types.js";
@@ -7,8 +10,14 @@ export type { Breadcrumb, BreadcrumbHint, CaptureContext, DebugImage, Event, Eve
7
10
  * Starts the SDK. Call it once, as early as you can. Calling it again replaces
8
11
  * the earlier setup. Without a valid DSN, or with enabled: false, nothing is
9
12
  * sent and nothing is patched.
13
+ *
14
+ * With a `schema` from defineSchema, its levels are sent as the Sentry level
15
+ * each ranks like, and the capture functions it returns take only the
16
+ * schema's levels, categories and fields.
10
17
  */
11
- export declare function init(options?: InitOptions): void;
18
+ export declare function init<const S extends SchemaSpec & CheckSchema<S> = SchemaSpec>(options?: InitOptions & {
19
+ schema?: S;
20
+ }): Handle<S>;
12
21
  /** Sends an error (or anything thrown). Returns the event id. */
13
22
  export declare function captureException(error: unknown, context?: CaptureContext): string;
14
23
  /**
package/dist/cjs/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SDK_VERSION = exports.SDK_NAME = exports.withScope = void 0;
3
+ exports.SDK_VERSION = exports.SDK_NAME = exports.defineSchema = exports.withScope = void 0;
4
4
  exports.init = init;
5
5
  exports.captureException = captureException;
6
6
  exports.captureMessage = captureMessage;
@@ -19,14 +19,11 @@ const hub_js_1 = require("./hub.js");
19
19
  const utils_js_1 = require("./utils.js");
20
20
  var hub_js_2 = require("./hub.js");
21
21
  Object.defineProperty(exports, "withScope", { enumerable: true, get: function () { return hub_js_2.withScope; } });
22
+ var schema_js_1 = require("./schema.js");
23
+ Object.defineProperty(exports, "defineSchema", { enumerable: true, get: function () { return schema_js_1.defineSchema; } });
22
24
  var version_js_1 = require("./version.js");
23
25
  Object.defineProperty(exports, "SDK_NAME", { enumerable: true, get: function () { return version_js_1.SDK_NAME; } });
24
26
  Object.defineProperty(exports, "SDK_VERSION", { enumerable: true, get: function () { return version_js_1.SDK_VERSION; } });
25
- /**
26
- * Starts the SDK. Call it once, as early as you can. Calling it again replaces
27
- * the earlier setup. Without a valid DSN, or with enabled: false, nothing is
28
- * sent and nothing is patched.
29
- */
30
27
  function init(options = {}) {
31
28
  try {
32
29
  const carrier = (0, hub_js_1.getCarrier)();
@@ -45,6 +42,7 @@ function init(options = {}) {
45
42
  (0, utils_js_1.safely)(() => console.error("[StatOSS] init failed:", error));
46
43
  }
47
44
  }
45
+ return HANDLE;
48
46
  }
49
47
  /** Sends an error (or anything thrown). Returns the event id. */
50
48
  function captureException(error, context) {
@@ -61,6 +59,13 @@ function captureMessage(message, levelOrContext) {
61
59
  function captureEvent(event, context) {
62
60
  return (0, hub_js_1.captureWith)(undefined, (client, scope) => client.captureEvent(event, context, scope));
63
61
  }
62
+ // What init returns: the same functions, typed by the schema. The client
63
+ // reads a context's fields whichever function it came through.
64
+ const HANDLE = Object.freeze({
65
+ captureException,
66
+ captureMessage,
67
+ captureEvent,
68
+ });
64
69
  /** Pass null to forget the user. */
65
70
  function setUser(user) {
66
71
  (0, utils_js_1.safely)(() => (0, hub_js_1.getCurrentScope)().setUser(user));
@@ -1,10 +1,13 @@
1
1
  import type { Logger } from "./logger.js";
2
2
  import type { SentryLevel } from "./types.js";
3
3
  export declare const SENTRY_LEVELS: readonly SentryLevel[];
4
+ export declare const LEVEL_RULE = "use 1 to 32 lowercase letters, digits, dots, dashes or underscores";
5
+ export declare const CATEGORY_RULE = "use 1 to 64 lowercase letters, digits, dots, dashes or underscores";
4
6
  export declare const LEVEL_TAG = "statoss.level";
5
7
  export declare const CATEGORY_TAG = "statoss.category";
6
8
  export declare function isSentryLevel(value: unknown): value is SentryLevel;
7
- export declare function isValidName(value: unknown): value is string;
9
+ export declare function isLevelName(value: unknown): value is string;
10
+ export declare function isCategoryName(value: unknown): value is string;
8
11
  /** Keeps the entries of init's `levels` that are usable, logging the rest in debug mode. */
9
12
  export declare function readLevels(input: unknown, logger: Logger): Record<string, SentryLevel>;
10
13
  export interface LevelChoice {
@@ -1,8 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.CATEGORY_TAG = exports.LEVEL_TAG = exports.SENTRY_LEVELS = void 0;
3
+ exports.CATEGORY_TAG = exports.LEVEL_TAG = exports.CATEGORY_RULE = exports.LEVEL_RULE = exports.SENTRY_LEVELS = void 0;
4
4
  exports.isSentryLevel = isSentryLevel;
5
- exports.isValidName = isValidName;
5
+ exports.isLevelName = isLevelName;
6
+ exports.isCategoryName = isCategoryName;
6
7
  exports.readLevels = readLevels;
7
8
  exports.chooseLevel = chooseLevel;
8
9
  exports.readCategory = readCategory;
@@ -14,16 +15,26 @@ exports.SENTRY_LEVELS = [
14
15
  "info",
15
16
  "debug",
16
17
  ];
17
- /** Custom level and category names: what the server accepts as tag values to filter on. */
18
- const NAME_RE = /^[a-z0-9._-]{1,64}$/;
18
+ /**
19
+ * Names of your own: lowercase letters, digits, dots, dashes and
20
+ * underscores. A level is at most 32 characters, as the server keeps it; a
21
+ * category at most 64.
22
+ */
23
+ const LEVEL_RE = /^[a-z0-9._-]{1,32}$/;
24
+ const CATEGORY_RE = /^[a-z0-9._-]{1,64}$/;
25
+ exports.LEVEL_RULE = "use 1 to 32 lowercase letters, digits, dots, dashes or underscores";
26
+ exports.CATEGORY_RULE = "use 1 to 64 lowercase letters, digits, dots, dashes or underscores";
19
27
  exports.LEVEL_TAG = "statoss.level";
20
28
  exports.CATEGORY_TAG = "statoss.category";
21
29
  function isSentryLevel(value) {
22
30
  return (typeof value === "string" &&
23
31
  exports.SENTRY_LEVELS.includes(value));
24
32
  }
25
- function isValidName(value) {
26
- return typeof value === "string" && NAME_RE.test(value);
33
+ function isLevelName(value) {
34
+ return typeof value === "string" && LEVEL_RE.test(value);
35
+ }
36
+ function isCategoryName(value) {
37
+ return typeof value === "string" && CATEGORY_RE.test(value);
27
38
  }
28
39
  /** Keeps the entries of init's `levels` that are usable, logging the rest in debug mode. */
29
40
  function readLevels(input, logger) {
@@ -38,8 +49,8 @@ function readLevels(input, logger) {
38
49
  if (isSentryLevel(name)) {
39
50
  logger.warn(`Ignored level "${name}" in \`levels\`: it is already a Sentry level.`);
40
51
  }
41
- else if (!isValidName(name)) {
42
- logger.warn(`Ignored level "${name}" in \`levels\`: use 1 to 64 lowercase letters, digits, dots, dashes or underscores.`);
52
+ else if (!isLevelName(name)) {
53
+ logger.warn(`Ignored level "${name}" in \`levels\`: ${exports.LEVEL_RULE}.`);
43
54
  }
44
55
  else if (!isSentryLevel(level)) {
45
56
  logger.warn(`Ignored level "${name}" in \`levels\`: map it to fatal, error, warning, info or debug.`);
@@ -60,18 +71,18 @@ function chooseLevel(candidates, levels, fallback, logger) {
60
71
  continue;
61
72
  if (isSentryLevel(candidate))
62
73
  return { level: candidate };
63
- if (isValidName(candidate)) {
74
+ if (isLevelName(candidate)) {
64
75
  return { level: levels[candidate] ?? "error", custom: candidate };
65
76
  }
66
- logger.warn(`Ignored level "${(0, utils_js_1.safeString)(candidate)}": use a Sentry level or 1 to 64 lowercase letters, digits, dots, dashes or underscores.`);
77
+ logger.warn(`Ignored level "${(0, utils_js_1.safeString)(candidate)}": use a Sentry level or 1 to 32 lowercase letters, digits, dots, dashes or underscores.`);
67
78
  }
68
79
  return { level: fallback };
69
80
  }
70
81
  function readCategory(value, logger) {
71
82
  if (value === undefined || value === null)
72
83
  return undefined;
73
- if (isValidName(value))
84
+ if (isCategoryName(value))
74
85
  return value;
75
- logger.warn(`Ignored category "${(0, utils_js_1.safeString)(value)}": use 1 to 64 lowercase letters, digits, dots, dashes or underscores.`);
86
+ logger.warn(`Ignored category "${(0, utils_js_1.safeString)(value)}": ${exports.CATEGORY_RULE}.`);
76
87
  return undefined;
77
88
  }
@@ -0,0 +1,199 @@
1
+ import type { Logger } from "./logger.js";
2
+ import type { CaptureContext, Event, Level, SentryLevel } from "./types.js";
3
+ /** The ranges a level ranks in: Sentry's five and OpenTelemetry's trace. */
4
+ export declare const LIKES: readonly ["fatal", "error", "warning", "info", "debug", "trace"];
5
+ export type Like = (typeof LIKES)[number];
6
+ /** The colours a level or a category takes, by name. */
7
+ export declare const SCHEMA_COLORS: readonly ["crimson", "red", "orange", "amber", "yellow", "green", "aqua", "teal", "blue", "violet", "indigo", "magenta", "plum", "grey"];
8
+ export type SchemaColor = (typeof SCHEMA_COLORS)[number];
9
+ /** What a rule tests: a tag by key, the logger, or one of the event's own fields. */
10
+ export declare const RULE_SUBJECTS: readonly ["tag", "logger", "exception", "culprit", "title", "message"];
11
+ export type RuleSubject = (typeof RULE_SUBJECTS)[number];
12
+ export declare const RULE_MATCHES: readonly ["is", "starts", "contains"];
13
+ export type RuleMatch = (typeof RULE_MATCHES)[number];
14
+ export declare const FIELD_TYPES: readonly ["text", "number"];
15
+ export type FieldType = (typeof FIELD_TYPES)[number];
16
+ /** Each range's severities, as OpenTelemetry numbers them. */
17
+ interface Severities {
18
+ fatal: 21 | 22 | 23 | 24;
19
+ error: 17 | 18 | 19 | 20;
20
+ warning: 13 | 14 | 15 | 16;
21
+ info: 9 | 10 | 11 | 12;
22
+ debug: 5 | 6 | 7 | 8;
23
+ trace: 1 | 2 | 3 | 4;
24
+ }
25
+ export type Severity = Severities[Like];
26
+ /** A level of your own. Null clears a setting. */
27
+ export interface LevelSettings {
28
+ /** The range it ranks in. Without it (and without severity) it ranks as error. */
29
+ like?: Like;
30
+ /** 1 to 24, inside the range of `like`. */
31
+ severity?: Severity;
32
+ label?: string | null;
33
+ color?: SchemaColor | null;
34
+ }
35
+ /** A category. Null clears a setting. */
36
+ export interface CategorySettings {
37
+ label?: string | null;
38
+ color?: SchemaColor | null;
39
+ /** Left out of filters and lists; its events still carry it. */
40
+ hidden?: boolean;
41
+ /** A team member's email. */
42
+ owner?: string | null;
43
+ }
44
+ /** A field: a tag key. Null clears a setting. */
45
+ export interface FieldSettings {
46
+ label?: string | null;
47
+ type?: FieldType;
48
+ unit?: string | null;
49
+ /** Gets a picker in Explore's filters. */
50
+ filterable?: boolean;
51
+ }
52
+ type RuleTests = {
53
+ subject: "tag";
54
+ tagKey: string;
55
+ } | {
56
+ subject: Exclude<RuleSubject, "tag">;
57
+ tagKey?: undefined;
58
+ };
59
+ type RuleSets<L extends string, C extends string> = {
60
+ level: L;
61
+ category?: C;
62
+ } | {
63
+ level?: L;
64
+ category: C;
65
+ };
66
+ /**
67
+ * Sets a level, a category or both for events that match: the first rule
68
+ * that matches wins. `match` defaults to "is".
69
+ */
70
+ export type SchemaRule<L extends string = string, C extends string = string> = RuleTests & {
71
+ match?: RuleMatch;
72
+ value: string;
73
+ } & RuleSets<L, C>;
74
+ /** An app's levels, categories, fields and rules, as the server takes them. */
75
+ export interface SchemaSpec {
76
+ /** Each name with the range it ranks in, or with its settings. */
77
+ levels?: {
78
+ readonly [name: string]: Like | LevelSettings;
79
+ };
80
+ categories?: readonly string[] | {
81
+ readonly [name: string]: CategorySettings;
82
+ };
83
+ fields?: readonly string[] | {
84
+ readonly [name: string]: FieldSettings;
85
+ };
86
+ rules?: readonly SchemaRule[];
87
+ }
88
+ type NamesIn<T> = T extends readonly (infer N)[] ? N & string : T extends object ? keyof T & string : never;
89
+ type LevelNames<S> = S extends {
90
+ levels?: infer L;
91
+ } ? NamesIn<NonNullable<L>> : never;
92
+ type CategoryNames<S> = S extends {
93
+ categories?: infer C;
94
+ } ? NamesIn<NonNullable<C>> : never;
95
+ type FieldNames<S> = S extends {
96
+ fields?: infer F;
97
+ } ? NamesIn<NonNullable<F>> : never;
98
+ /** A Sentry level or a level of the schema. */
99
+ export type LevelOf<S> = string extends LevelNames<S> ? Level : SentryLevel | LevelNames<S>;
100
+ /** A category of the schema. */
101
+ export type CategoryOf<S> = CategoryNames<S>;
102
+ /** A field of the schema. */
103
+ export type FieldOf<S> = FieldNames<S>;
104
+ type NameChar = "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z" | "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "." | "-" | "_";
105
+ /** True for 1 to Max of NameChar. A name only known as string passes. */
106
+ type IsName<S extends string, Max extends number, Seen extends 0[] = []> = string extends S ? true : S extends `${infer C}${infer Rest}` ? C extends NameChar ? Seen["length"] extends Max ? false : IsName<Rest, Max, [...Seen, 0]> : false : Seen extends [] ? false : true;
107
+ type LevelRule = "Level names are 1 to 32 lowercase letters, digits, dots, dashes or underscores";
108
+ type CategoryRule = "Category names are 1 to 64 lowercase letters, digits, dots, dashes or underscores";
109
+ type FieldRule = "Field names are not empty and do not start statoss.";
110
+ type Only<V, Keys extends PropertyKey, Message extends string> = {
111
+ [P in Exclude<keyof V, Keys>]: {
112
+ [M in Message]: true;
113
+ };
114
+ };
115
+ type LevelCheck<V> = V extends string ? Like : {
116
+ like?: Like;
117
+ severity?: V extends {
118
+ like: infer K extends Like;
119
+ } ? Severities[K] : Severity;
120
+ label?: string | null;
121
+ color?: SchemaColor | null;
122
+ } & Only<V, keyof LevelSettings, "A level takes like, severity, label and color">;
123
+ /** Sentry's own levels rank as themselves: a label and a colour only. */
124
+ type BuiltinLevelCheck<K extends SentryLevel, V> = V extends string ? K : {
125
+ like?: K;
126
+ label?: string | null;
127
+ color?: SchemaColor | null;
128
+ } & Only<V, "like" | "label" | "color", "A built-in level takes label and color, and ranks as itself">;
129
+ type CheckLevels<T> = {
130
+ [K in keyof T]: K extends SentryLevel ? BuiltinLevelCheck<K, T[K]> : K extends string ? IsName<K, 32> extends true ? LevelCheck<T[K]> : {
131
+ [M in LevelRule]: true;
132
+ } : never;
133
+ };
134
+ type CheckCategories<T> = T extends readonly unknown[] ? {
135
+ [I in keyof T]: T[I] extends string ? IsName<T[I], 64> extends true ? T[I] : {
136
+ [M in CategoryRule]: true;
137
+ } : string;
138
+ } : {
139
+ [K in keyof T]: K extends string ? IsName<K, 64> extends true ? CategorySettings & Only<T[K], keyof CategorySettings, "A category takes label, color, hidden and owner"> : {
140
+ [M in CategoryRule]: true;
141
+ } : never;
142
+ };
143
+ type IsField<K extends string> = K extends "" | `statoss.${string}` ? false : true;
144
+ type CheckFields<T> = T extends readonly unknown[] ? {
145
+ [I in keyof T]: T[I] extends string ? IsField<T[I]> extends true ? T[I] : {
146
+ [M in FieldRule]: true;
147
+ } : string;
148
+ } : {
149
+ [K in keyof T]: K extends string ? IsField<K> extends true ? FieldSettings & Only<T[K], keyof FieldSettings, "A field takes label, type, unit and filterable"> : {
150
+ [M in FieldRule]: true;
151
+ } : never;
152
+ };
153
+ type CheckRules<T, L extends string, C extends string> = {
154
+ [I in keyof T]: SchemaRule<L, C> & Only<T[I], "subject" | "tagKey" | "match" | "value" | "level" | "category", "A rule takes subject, tagKey, match, value, level and category">;
155
+ };
156
+ /** What defineSchema holds a schema to, read from the schema itself. */
157
+ export type CheckSchema<S> = {
158
+ [K in keyof S]: K extends "levels" ? CheckLevels<S[K]> : K extends "categories" ? CheckCategories<S[K]> : K extends "fields" ? CheckFields<S[K]> : K extends "rules" ? CheckRules<S[K], SentryLevel | LevelNames<S>, CategoryNames<S>> : {
159
+ "A schema takes levels, categories, fields and rules": true;
160
+ };
161
+ };
162
+ /**
163
+ * Declares an app's levels, categories, fields and rules. Returns the schema
164
+ * as given: plain data in the shape the server takes, so it can be sent as
165
+ * it is. Its names become types: pass it to init as `schema` and the capture
166
+ * functions init returns take only these names.
167
+ */
168
+ export declare function defineSchema<const S extends SchemaSpec & CheckSchema<S>>(schema: S): S;
169
+ /** A field's value; sent as a tag. */
170
+ export type FieldValue = string | number | boolean;
171
+ /** What the capture functions of init's handle take, typed by the schema. */
172
+ export interface SchemaContext<S = SchemaSpec> extends Omit<CaptureContext, "level" | "category"> {
173
+ /** A Sentry level or a level of the schema. */
174
+ level?: LevelOf<S>;
175
+ /** A category of the schema, sent as the `statoss.category` tag. */
176
+ category?: CategoryOf<S>;
177
+ /**
178
+ * Fields of the schema, sent as tags; a schema that declares none takes
179
+ * none here.
180
+ */
181
+ fields?: [FieldOf<S>] extends [never] ? never : {
182
+ [K in FieldOf<S>]?: FieldValue;
183
+ };
184
+ }
185
+ /** The capture functions, typed by the schema given to init. */
186
+ export interface Handle<S = SchemaSpec> {
187
+ /** Sends an error (or anything thrown). Returns the event id. */
188
+ captureException(error: unknown, context?: SchemaContext<S>): string;
189
+ /** Sends a message, with a level or a context. Returns the event id. */
190
+ captureMessage(message: string, levelOrContext?: LevelOf<S> | SchemaContext<S>): string;
191
+ /** Sends an event you built in Sentry's schema. Returns the event id. */
192
+ captureEvent(event: Event, context?: SchemaContext<S>): string;
193
+ }
194
+ /**
195
+ * The Sentry level each level of init's `schema` is sent as. Built-in
196
+ * levels are left out; names the SDK cannot send are logged in debug mode.
197
+ */
198
+ export declare function readSchema(schema: unknown, logger: Logger): Record<string, SentryLevel>;
199
+ export {};
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FIELD_TYPES = exports.RULE_MATCHES = exports.RULE_SUBJECTS = exports.SCHEMA_COLORS = exports.LIKES = void 0;
4
+ exports.defineSchema = defineSchema;
5
+ exports.readSchema = readSchema;
6
+ const levels_js_1 = require("./levels.js");
7
+ const utils_js_1 = require("./utils.js");
8
+ // The server's own lists (src/lib/app-schema.ts, src/lib/schema-colors.ts,
9
+ // src/db/schema.ts), kept equal by a test.
10
+ /** The ranges a level ranks in: Sentry's five and OpenTelemetry's trace. */
11
+ exports.LIKES = [
12
+ "fatal",
13
+ "error",
14
+ "warning",
15
+ "info",
16
+ "debug",
17
+ "trace",
18
+ ];
19
+ /** The colours a level or a category takes, by name. */
20
+ exports.SCHEMA_COLORS = [
21
+ "crimson",
22
+ "red",
23
+ "orange",
24
+ "amber",
25
+ "yellow",
26
+ "green",
27
+ "aqua",
28
+ "teal",
29
+ "blue",
30
+ "violet",
31
+ "indigo",
32
+ "magenta",
33
+ "plum",
34
+ "grey",
35
+ ];
36
+ /** What a rule tests: a tag by key, the logger, or one of the event's own fields. */
37
+ exports.RULE_SUBJECTS = [
38
+ "tag",
39
+ "logger",
40
+ "exception",
41
+ "culprit",
42
+ "title",
43
+ "message",
44
+ ];
45
+ exports.RULE_MATCHES = ["is", "starts", "contains"];
46
+ exports.FIELD_TYPES = ["text", "number"];
47
+ /**
48
+ * Declares an app's levels, categories, fields and rules. Returns the schema
49
+ * as given: plain data in the shape the server takes, so it can be sent as
50
+ * it is. Its names become types: pass it to init as `schema` and the capture
51
+ * functions init returns take only these names.
52
+ */
53
+ function defineSchema(schema) {
54
+ return schema;
55
+ }
56
+ // At runtime
57
+ /** The Sentry level a level of the schema is sent as. */
58
+ function sentAs(name, value, logger) {
59
+ const settings = (0, utils_js_1.isObject)(value) ? value : {};
60
+ const like = typeof value === "string" ? value : settings.like;
61
+ if ((0, levels_js_1.isSentryLevel)(like))
62
+ return like;
63
+ if (like === "trace")
64
+ return "debug";
65
+ if (like !== undefined || (typeof value !== "string" && !(0, utils_js_1.isObject)(value))) {
66
+ logger.warn(`Level "${name}" in the schema: like should be ${exports.LIKES.join(", ")}; sent as error.`);
67
+ return "error";
68
+ }
69
+ const severity = settings.severity;
70
+ if (typeof severity !== "number" || severity < 1 || severity > 24)
71
+ return "error";
72
+ if (severity >= 21)
73
+ return "fatal";
74
+ if (severity >= 17)
75
+ return "error";
76
+ if (severity >= 13)
77
+ return "warning";
78
+ if (severity >= 9)
79
+ return "info";
80
+ return "debug";
81
+ }
82
+ /**
83
+ * The Sentry level each level of init's `schema` is sent as. Built-in
84
+ * levels are left out; names the SDK cannot send are logged in debug mode.
85
+ */
86
+ function readSchema(schema, logger) {
87
+ const out = {};
88
+ if (schema === undefined || schema === null)
89
+ return out;
90
+ if (!(0, utils_js_1.isObject)(schema) || Array.isArray(schema)) {
91
+ logger.warn("Ignored `schema`: pass what defineSchema returns.");
92
+ return out;
93
+ }
94
+ const { levels, categories } = schema;
95
+ if ((0, utils_js_1.isObject)(levels) && !Array.isArray(levels)) {
96
+ for (const [name, value] of Object.entries(levels)) {
97
+ // A built-in level, given a label or a colour.
98
+ if ((0, levels_js_1.isSentryLevel)(name))
99
+ continue;
100
+ if ((0, levels_js_1.isLevelName)(name))
101
+ out[name] = sentAs(name, value, logger);
102
+ else
103
+ logger.warn(`Ignored level "${name}" in the schema: ${levels_js_1.LEVEL_RULE}.`);
104
+ }
105
+ }
106
+ else if (levels !== undefined && levels !== null) {
107
+ logger.warn('Ignored the schema\'s levels: they should be an object such as { "payment-failed": "error" }.');
108
+ }
109
+ const names = Array.isArray(categories)
110
+ ? categories
111
+ : (0, utils_js_1.isObject)(categories)
112
+ ? Object.keys(categories)
113
+ : [];
114
+ for (const name of names) {
115
+ if (!(0, levels_js_1.isCategoryName)(name))
116
+ logger.warn(`Category "${(0, utils_js_1.safeString)(name)}" in the schema cannot be sent: ${levels_js_1.CATEGORY_RULE}.`);
117
+ }
118
+ return out;
119
+ }
@@ -1,3 +1,3 @@
1
1
  export declare const SDK_NAME = "statoss.javascript";
2
- export declare const SDK_VERSION = "0.2.0";
2
+ export declare const SDK_VERSION = "0.3.0";
3
3
  export declare const SDK_PACKAGE = "npm:@statoss/sdk";
@@ -3,5 +3,5 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.SDK_PACKAGE = exports.SDK_VERSION = exports.SDK_NAME = void 0;
4
4
  exports.SDK_NAME = "statoss.javascript";
5
5
  // Kept equal to package.json by a test.
6
- exports.SDK_VERSION = "0.2.0";
6
+ exports.SDK_VERSION = "0.3.0";
7
7
  exports.SDK_PACKAGE = "npm:@statoss/sdk";
@@ -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;
@@ -20,7 +24,7 @@ export declare class Client {
20
24
  private reportTimer?;
21
25
  private previousSignature?;
22
26
  private closed;
23
- constructor(options: InitOptions);
27
+ constructor(options: ClientOptions);
24
28
  /** Installs the integrations that apply to this runtime. */
25
29
  setup(): void;
26
30
  captureException(error: unknown, context: CaptureContext | undefined, scope: Scope): string;
@@ -11,6 +11,7 @@ import { installGlobalHandlers } from "./integrations/handlers.js";
11
11
  import { CATEGORY_TAG, chooseLevel, isSentryLevel, LEVEL_TAG, readCategory, readLevels, } from "./levels.js";
12
12
  import { makeLogger } from "./logger.js";
13
13
  import { describeRuntime } from "./runtime.js";
14
+ import { readSchema } from "./schema.js";
14
15
  import { MAX_BREADCRUMBS } from "./scope.js";
15
16
  import { fetchTransport, Sender } from "./transport.js";
16
17
  import { isObject, isThenable, normalize, now, safely, safeString, scrubUrl, truncate, unref, uuid4, } from "./utils.js";
@@ -51,6 +52,32 @@ function cleanTags(tags) {
51
52
  }
52
53
  return out;
53
54
  }
55
+ /** A capture's fields, as tags. Keys starting statoss. are not fields. */
56
+ function fieldTags(fields, logger) {
57
+ const out = {};
58
+ if (fields === undefined || fields === null)
59
+ return out;
60
+ if (!isObject(fields) || Array.isArray(fields)) {
61
+ logger.warn('Ignored `fields`: it should be an object such as { route: "/pay" }.');
62
+ return out;
63
+ }
64
+ for (const [key, value] of Object.entries(fields)) {
65
+ if (value === undefined || value === null)
66
+ continue;
67
+ if (key === "" || key.startsWith("statoss.")) {
68
+ logger.warn(`Ignored field "${key}": fields are not empty and do not start statoss.`);
69
+ }
70
+ else if (typeof value === "string" ||
71
+ typeof value === "number" ||
72
+ typeof value === "boolean") {
73
+ out[key] = value;
74
+ }
75
+ else {
76
+ logger.warn(`Ignored field "${key}": its value should be a string, number or boolean.`);
77
+ }
78
+ }
79
+ return out;
80
+ }
54
81
  function cleanContexts(contexts) {
55
82
  const out = {};
56
83
  for (const [name, value] of Object.entries(contexts)) {
@@ -100,7 +127,11 @@ export class Client {
100
127
  this.options = options;
101
128
  this.logger = makeLogger(options.debug === true);
102
129
  this.runtime = describeRuntime();
103
- this.levels = readLevels(options.levels, this.logger);
130
+ // What `levels` says wins over the schema.
131
+ this.levels = {
132
+ ...readSchema(options.schema, this.logger),
133
+ ...readLevels(options.levels, this.logger),
134
+ };
104
135
  const rate = options.sampleRate;
105
136
  if (rate !== undefined &&
106
137
  !(typeof rate === "number" && rate >= 0 && rate <= 1)) {
@@ -326,6 +357,7 @@ export class Client {
326
357
  const tags = {
327
358
  ...scope._tags,
328
359
  ...ctx.tags,
360
+ ...fieldTags(ctx.fields, this.logger),
329
361
  ...base.tags,
330
362
  };
331
363
  if (choice.custom)
@@ -1,5 +1,8 @@
1
+ import type { CheckSchema, Handle, SchemaSpec } from "./schema.js";
1
2
  import type { Breadcrumb, CaptureContext, Event, InitOptions, Level, Primitive, User } from "./types.js";
2
3
  export { withScope } from "./hub.js";
4
+ export { defineSchema } from "./schema.js";
5
+ export type { CategoryOf, CategorySettings, CheckSchema, FieldOf, FieldSettings, FieldType, FieldValue, Handle, LevelOf, LevelSettings, Like, RuleMatch, RuleSubject, SchemaColor, SchemaContext, SchemaRule, SchemaSpec, Severity, } from "./schema.js";
3
6
  export type { Scope } from "./scope.js";
4
7
  export { SDK_NAME, SDK_VERSION } from "./version.js";
5
8
  export type { Breadcrumb, BreadcrumbHint, CaptureContext, DebugImage, Event, EventHint, Exception, InitOptions, IntegrationToggles, Level, Mechanism, Primitive, RequestInfo, ScopeData, SdkInfo, SentryLevel, StackFrame, Transport, TransportRequest, TransportResponse, User, } from "./types.js";
@@ -7,8 +10,14 @@ export type { Breadcrumb, BreadcrumbHint, CaptureContext, DebugImage, Event, Eve
7
10
  * Starts the SDK. Call it once, as early as you can. Calling it again replaces
8
11
  * the earlier setup. Without a valid DSN, or with enabled: false, nothing is
9
12
  * sent and nothing is patched.
13
+ *
14
+ * With a `schema` from defineSchema, its levels are sent as the Sentry level
15
+ * each ranks like, and the capture functions it returns take only the
16
+ * schema's levels, categories and fields.
10
17
  */
11
- export declare function init(options?: InitOptions): void;
18
+ export declare function init<const S extends SchemaSpec & CheckSchema<S> = SchemaSpec>(options?: InitOptions & {
19
+ schema?: S;
20
+ }): Handle<S>;
12
21
  /** Sends an error (or anything thrown). Returns the event id. */
13
22
  export declare function captureException(error: unknown, context?: CaptureContext): string;
14
23
  /**
package/dist/esm/index.js CHANGED
@@ -2,12 +2,8 @@ import { Client } from "./client.js";
2
2
  import { captureWith, getCarrier, getCurrentScope, messageContext, } from "./hub.js";
3
3
  import { isObject, safely } from "./utils.js";
4
4
  export { withScope } from "./hub.js";
5
+ export { defineSchema } from "./schema.js";
5
6
  export { SDK_NAME, SDK_VERSION } from "./version.js";
6
- /**
7
- * Starts the SDK. Call it once, as early as you can. Calling it again replaces
8
- * the earlier setup. Without a valid DSN, or with enabled: false, nothing is
9
- * sent and nothing is patched.
10
- */
11
7
  export function init(options = {}) {
12
8
  try {
13
9
  const carrier = getCarrier();
@@ -26,6 +22,7 @@ export function init(options = {}) {
26
22
  safely(() => console.error("[StatOSS] init failed:", error));
27
23
  }
28
24
  }
25
+ return HANDLE;
29
26
  }
30
27
  /** Sends an error (or anything thrown). Returns the event id. */
31
28
  export function captureException(error, context) {
@@ -42,6 +39,13 @@ export function captureMessage(message, levelOrContext) {
42
39
  export function captureEvent(event, context) {
43
40
  return captureWith(undefined, (client, scope) => client.captureEvent(event, context, scope));
44
41
  }
42
+ // What init returns: the same functions, typed by the schema. The client
43
+ // reads a context's fields whichever function it came through.
44
+ const HANDLE = Object.freeze({
45
+ captureException,
46
+ captureMessage,
47
+ captureEvent,
48
+ });
45
49
  /** Pass null to forget the user. */
46
50
  export function setUser(user) {
47
51
  safely(() => getCurrentScope().setUser(user));
@@ -1,10 +1,13 @@
1
1
  import type { Logger } from "./logger.js";
2
2
  import type { SentryLevel } from "./types.js";
3
3
  export declare const SENTRY_LEVELS: readonly SentryLevel[];
4
+ export declare const LEVEL_RULE = "use 1 to 32 lowercase letters, digits, dots, dashes or underscores";
5
+ export declare const CATEGORY_RULE = "use 1 to 64 lowercase letters, digits, dots, dashes or underscores";
4
6
  export declare const LEVEL_TAG = "statoss.level";
5
7
  export declare const CATEGORY_TAG = "statoss.category";
6
8
  export declare function isSentryLevel(value: unknown): value is SentryLevel;
7
- export declare function isValidName(value: unknown): value is string;
9
+ export declare function isLevelName(value: unknown): value is string;
10
+ export declare function isCategoryName(value: unknown): value is string;
8
11
  /** Keeps the entries of init's `levels` that are usable, logging the rest in debug mode. */
9
12
  export declare function readLevels(input: unknown, logger: Logger): Record<string, SentryLevel>;
10
13
  export interface LevelChoice {
@@ -6,16 +6,26 @@ export const SENTRY_LEVELS = [
6
6
  "info",
7
7
  "debug",
8
8
  ];
9
- /** Custom level and category names: what the server accepts as tag values to filter on. */
10
- const NAME_RE = /^[a-z0-9._-]{1,64}$/;
9
+ /**
10
+ * Names of your own: lowercase letters, digits, dots, dashes and
11
+ * underscores. A level is at most 32 characters, as the server keeps it; a
12
+ * category at most 64.
13
+ */
14
+ const LEVEL_RE = /^[a-z0-9._-]{1,32}$/;
15
+ const CATEGORY_RE = /^[a-z0-9._-]{1,64}$/;
16
+ export const LEVEL_RULE = "use 1 to 32 lowercase letters, digits, dots, dashes or underscores";
17
+ export const CATEGORY_RULE = "use 1 to 64 lowercase letters, digits, dots, dashes or underscores";
11
18
  export const LEVEL_TAG = "statoss.level";
12
19
  export const CATEGORY_TAG = "statoss.category";
13
20
  export function isSentryLevel(value) {
14
21
  return (typeof value === "string" &&
15
22
  SENTRY_LEVELS.includes(value));
16
23
  }
17
- export function isValidName(value) {
18
- return typeof value === "string" && NAME_RE.test(value);
24
+ export function isLevelName(value) {
25
+ return typeof value === "string" && LEVEL_RE.test(value);
26
+ }
27
+ export function isCategoryName(value) {
28
+ return typeof value === "string" && CATEGORY_RE.test(value);
19
29
  }
20
30
  /** Keeps the entries of init's `levels` that are usable, logging the rest in debug mode. */
21
31
  export function readLevels(input, logger) {
@@ -30,8 +40,8 @@ export function readLevels(input, logger) {
30
40
  if (isSentryLevel(name)) {
31
41
  logger.warn(`Ignored level "${name}" in \`levels\`: it is already a Sentry level.`);
32
42
  }
33
- else if (!isValidName(name)) {
34
- logger.warn(`Ignored level "${name}" in \`levels\`: use 1 to 64 lowercase letters, digits, dots, dashes or underscores.`);
43
+ else if (!isLevelName(name)) {
44
+ logger.warn(`Ignored level "${name}" in \`levels\`: ${LEVEL_RULE}.`);
35
45
  }
36
46
  else if (!isSentryLevel(level)) {
37
47
  logger.warn(`Ignored level "${name}" in \`levels\`: map it to fatal, error, warning, info or debug.`);
@@ -52,18 +62,18 @@ export function chooseLevel(candidates, levels, fallback, logger) {
52
62
  continue;
53
63
  if (isSentryLevel(candidate))
54
64
  return { level: candidate };
55
- if (isValidName(candidate)) {
65
+ if (isLevelName(candidate)) {
56
66
  return { level: levels[candidate] ?? "error", custom: candidate };
57
67
  }
58
- logger.warn(`Ignored level "${safeString(candidate)}": use a Sentry level or 1 to 64 lowercase letters, digits, dots, dashes or underscores.`);
68
+ logger.warn(`Ignored level "${safeString(candidate)}": use a Sentry level or 1 to 32 lowercase letters, digits, dots, dashes or underscores.`);
59
69
  }
60
70
  return { level: fallback };
61
71
  }
62
72
  export function readCategory(value, logger) {
63
73
  if (value === undefined || value === null)
64
74
  return undefined;
65
- if (isValidName(value))
75
+ if (isCategoryName(value))
66
76
  return value;
67
- logger.warn(`Ignored category "${safeString(value)}": use 1 to 64 lowercase letters, digits, dots, dashes or underscores.`);
77
+ logger.warn(`Ignored category "${safeString(value)}": ${CATEGORY_RULE}.`);
68
78
  return undefined;
69
79
  }
@@ -0,0 +1,199 @@
1
+ import type { Logger } from "./logger.js";
2
+ import type { CaptureContext, Event, Level, SentryLevel } from "./types.js";
3
+ /** The ranges a level ranks in: Sentry's five and OpenTelemetry's trace. */
4
+ export declare const LIKES: readonly ["fatal", "error", "warning", "info", "debug", "trace"];
5
+ export type Like = (typeof LIKES)[number];
6
+ /** The colours a level or a category takes, by name. */
7
+ export declare const SCHEMA_COLORS: readonly ["crimson", "red", "orange", "amber", "yellow", "green", "aqua", "teal", "blue", "violet", "indigo", "magenta", "plum", "grey"];
8
+ export type SchemaColor = (typeof SCHEMA_COLORS)[number];
9
+ /** What a rule tests: a tag by key, the logger, or one of the event's own fields. */
10
+ export declare const RULE_SUBJECTS: readonly ["tag", "logger", "exception", "culprit", "title", "message"];
11
+ export type RuleSubject = (typeof RULE_SUBJECTS)[number];
12
+ export declare const RULE_MATCHES: readonly ["is", "starts", "contains"];
13
+ export type RuleMatch = (typeof RULE_MATCHES)[number];
14
+ export declare const FIELD_TYPES: readonly ["text", "number"];
15
+ export type FieldType = (typeof FIELD_TYPES)[number];
16
+ /** Each range's severities, as OpenTelemetry numbers them. */
17
+ interface Severities {
18
+ fatal: 21 | 22 | 23 | 24;
19
+ error: 17 | 18 | 19 | 20;
20
+ warning: 13 | 14 | 15 | 16;
21
+ info: 9 | 10 | 11 | 12;
22
+ debug: 5 | 6 | 7 | 8;
23
+ trace: 1 | 2 | 3 | 4;
24
+ }
25
+ export type Severity = Severities[Like];
26
+ /** A level of your own. Null clears a setting. */
27
+ export interface LevelSettings {
28
+ /** The range it ranks in. Without it (and without severity) it ranks as error. */
29
+ like?: Like;
30
+ /** 1 to 24, inside the range of `like`. */
31
+ severity?: Severity;
32
+ label?: string | null;
33
+ color?: SchemaColor | null;
34
+ }
35
+ /** A category. Null clears a setting. */
36
+ export interface CategorySettings {
37
+ label?: string | null;
38
+ color?: SchemaColor | null;
39
+ /** Left out of filters and lists; its events still carry it. */
40
+ hidden?: boolean;
41
+ /** A team member's email. */
42
+ owner?: string | null;
43
+ }
44
+ /** A field: a tag key. Null clears a setting. */
45
+ export interface FieldSettings {
46
+ label?: string | null;
47
+ type?: FieldType;
48
+ unit?: string | null;
49
+ /** Gets a picker in Explore's filters. */
50
+ filterable?: boolean;
51
+ }
52
+ type RuleTests = {
53
+ subject: "tag";
54
+ tagKey: string;
55
+ } | {
56
+ subject: Exclude<RuleSubject, "tag">;
57
+ tagKey?: undefined;
58
+ };
59
+ type RuleSets<L extends string, C extends string> = {
60
+ level: L;
61
+ category?: C;
62
+ } | {
63
+ level?: L;
64
+ category: C;
65
+ };
66
+ /**
67
+ * Sets a level, a category or both for events that match: the first rule
68
+ * that matches wins. `match` defaults to "is".
69
+ */
70
+ export type SchemaRule<L extends string = string, C extends string = string> = RuleTests & {
71
+ match?: RuleMatch;
72
+ value: string;
73
+ } & RuleSets<L, C>;
74
+ /** An app's levels, categories, fields and rules, as the server takes them. */
75
+ export interface SchemaSpec {
76
+ /** Each name with the range it ranks in, or with its settings. */
77
+ levels?: {
78
+ readonly [name: string]: Like | LevelSettings;
79
+ };
80
+ categories?: readonly string[] | {
81
+ readonly [name: string]: CategorySettings;
82
+ };
83
+ fields?: readonly string[] | {
84
+ readonly [name: string]: FieldSettings;
85
+ };
86
+ rules?: readonly SchemaRule[];
87
+ }
88
+ type NamesIn<T> = T extends readonly (infer N)[] ? N & string : T extends object ? keyof T & string : never;
89
+ type LevelNames<S> = S extends {
90
+ levels?: infer L;
91
+ } ? NamesIn<NonNullable<L>> : never;
92
+ type CategoryNames<S> = S extends {
93
+ categories?: infer C;
94
+ } ? NamesIn<NonNullable<C>> : never;
95
+ type FieldNames<S> = S extends {
96
+ fields?: infer F;
97
+ } ? NamesIn<NonNullable<F>> : never;
98
+ /** A Sentry level or a level of the schema. */
99
+ export type LevelOf<S> = string extends LevelNames<S> ? Level : SentryLevel | LevelNames<S>;
100
+ /** A category of the schema. */
101
+ export type CategoryOf<S> = CategoryNames<S>;
102
+ /** A field of the schema. */
103
+ export type FieldOf<S> = FieldNames<S>;
104
+ type NameChar = "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h" | "i" | "j" | "k" | "l" | "m" | "n" | "o" | "p" | "q" | "r" | "s" | "t" | "u" | "v" | "w" | "x" | "y" | "z" | "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "." | "-" | "_";
105
+ /** True for 1 to Max of NameChar. A name only known as string passes. */
106
+ type IsName<S extends string, Max extends number, Seen extends 0[] = []> = string extends S ? true : S extends `${infer C}${infer Rest}` ? C extends NameChar ? Seen["length"] extends Max ? false : IsName<Rest, Max, [...Seen, 0]> : false : Seen extends [] ? false : true;
107
+ type LevelRule = "Level names are 1 to 32 lowercase letters, digits, dots, dashes or underscores";
108
+ type CategoryRule = "Category names are 1 to 64 lowercase letters, digits, dots, dashes or underscores";
109
+ type FieldRule = "Field names are not empty and do not start statoss.";
110
+ type Only<V, Keys extends PropertyKey, Message extends string> = {
111
+ [P in Exclude<keyof V, Keys>]: {
112
+ [M in Message]: true;
113
+ };
114
+ };
115
+ type LevelCheck<V> = V extends string ? Like : {
116
+ like?: Like;
117
+ severity?: V extends {
118
+ like: infer K extends Like;
119
+ } ? Severities[K] : Severity;
120
+ label?: string | null;
121
+ color?: SchemaColor | null;
122
+ } & Only<V, keyof LevelSettings, "A level takes like, severity, label and color">;
123
+ /** Sentry's own levels rank as themselves: a label and a colour only. */
124
+ type BuiltinLevelCheck<K extends SentryLevel, V> = V extends string ? K : {
125
+ like?: K;
126
+ label?: string | null;
127
+ color?: SchemaColor | null;
128
+ } & Only<V, "like" | "label" | "color", "A built-in level takes label and color, and ranks as itself">;
129
+ type CheckLevels<T> = {
130
+ [K in keyof T]: K extends SentryLevel ? BuiltinLevelCheck<K, T[K]> : K extends string ? IsName<K, 32> extends true ? LevelCheck<T[K]> : {
131
+ [M in LevelRule]: true;
132
+ } : never;
133
+ };
134
+ type CheckCategories<T> = T extends readonly unknown[] ? {
135
+ [I in keyof T]: T[I] extends string ? IsName<T[I], 64> extends true ? T[I] : {
136
+ [M in CategoryRule]: true;
137
+ } : string;
138
+ } : {
139
+ [K in keyof T]: K extends string ? IsName<K, 64> extends true ? CategorySettings & Only<T[K], keyof CategorySettings, "A category takes label, color, hidden and owner"> : {
140
+ [M in CategoryRule]: true;
141
+ } : never;
142
+ };
143
+ type IsField<K extends string> = K extends "" | `statoss.${string}` ? false : true;
144
+ type CheckFields<T> = T extends readonly unknown[] ? {
145
+ [I in keyof T]: T[I] extends string ? IsField<T[I]> extends true ? T[I] : {
146
+ [M in FieldRule]: true;
147
+ } : string;
148
+ } : {
149
+ [K in keyof T]: K extends string ? IsField<K> extends true ? FieldSettings & Only<T[K], keyof FieldSettings, "A field takes label, type, unit and filterable"> : {
150
+ [M in FieldRule]: true;
151
+ } : never;
152
+ };
153
+ type CheckRules<T, L extends string, C extends string> = {
154
+ [I in keyof T]: SchemaRule<L, C> & Only<T[I], "subject" | "tagKey" | "match" | "value" | "level" | "category", "A rule takes subject, tagKey, match, value, level and category">;
155
+ };
156
+ /** What defineSchema holds a schema to, read from the schema itself. */
157
+ export type CheckSchema<S> = {
158
+ [K in keyof S]: K extends "levels" ? CheckLevels<S[K]> : K extends "categories" ? CheckCategories<S[K]> : K extends "fields" ? CheckFields<S[K]> : K extends "rules" ? CheckRules<S[K], SentryLevel | LevelNames<S>, CategoryNames<S>> : {
159
+ "A schema takes levels, categories, fields and rules": true;
160
+ };
161
+ };
162
+ /**
163
+ * Declares an app's levels, categories, fields and rules. Returns the schema
164
+ * as given: plain data in the shape the server takes, so it can be sent as
165
+ * it is. Its names become types: pass it to init as `schema` and the capture
166
+ * functions init returns take only these names.
167
+ */
168
+ export declare function defineSchema<const S extends SchemaSpec & CheckSchema<S>>(schema: S): S;
169
+ /** A field's value; sent as a tag. */
170
+ export type FieldValue = string | number | boolean;
171
+ /** What the capture functions of init's handle take, typed by the schema. */
172
+ export interface SchemaContext<S = SchemaSpec> extends Omit<CaptureContext, "level" | "category"> {
173
+ /** A Sentry level or a level of the schema. */
174
+ level?: LevelOf<S>;
175
+ /** A category of the schema, sent as the `statoss.category` tag. */
176
+ category?: CategoryOf<S>;
177
+ /**
178
+ * Fields of the schema, sent as tags; a schema that declares none takes
179
+ * none here.
180
+ */
181
+ fields?: [FieldOf<S>] extends [never] ? never : {
182
+ [K in FieldOf<S>]?: FieldValue;
183
+ };
184
+ }
185
+ /** The capture functions, typed by the schema given to init. */
186
+ export interface Handle<S = SchemaSpec> {
187
+ /** Sends an error (or anything thrown). Returns the event id. */
188
+ captureException(error: unknown, context?: SchemaContext<S>): string;
189
+ /** Sends a message, with a level or a context. Returns the event id. */
190
+ captureMessage(message: string, levelOrContext?: LevelOf<S> | SchemaContext<S>): string;
191
+ /** Sends an event you built in Sentry's schema. Returns the event id. */
192
+ captureEvent(event: Event, context?: SchemaContext<S>): string;
193
+ }
194
+ /**
195
+ * The Sentry level each level of init's `schema` is sent as. Built-in
196
+ * levels are left out; names the SDK cannot send are logged in debug mode.
197
+ */
198
+ export declare function readSchema(schema: unknown, logger: Logger): Record<string, SentryLevel>;
199
+ export {};
@@ -0,0 +1,114 @@
1
+ import { CATEGORY_RULE, isCategoryName, isLevelName, isSentryLevel, LEVEL_RULE, } from "./levels.js";
2
+ import { isObject, safeString } from "./utils.js";
3
+ // The server's own lists (src/lib/app-schema.ts, src/lib/schema-colors.ts,
4
+ // src/db/schema.ts), kept equal by a test.
5
+ /** The ranges a level ranks in: Sentry's five and OpenTelemetry's trace. */
6
+ export const LIKES = [
7
+ "fatal",
8
+ "error",
9
+ "warning",
10
+ "info",
11
+ "debug",
12
+ "trace",
13
+ ];
14
+ /** The colours a level or a category takes, by name. */
15
+ export const SCHEMA_COLORS = [
16
+ "crimson",
17
+ "red",
18
+ "orange",
19
+ "amber",
20
+ "yellow",
21
+ "green",
22
+ "aqua",
23
+ "teal",
24
+ "blue",
25
+ "violet",
26
+ "indigo",
27
+ "magenta",
28
+ "plum",
29
+ "grey",
30
+ ];
31
+ /** What a rule tests: a tag by key, the logger, or one of the event's own fields. */
32
+ export const RULE_SUBJECTS = [
33
+ "tag",
34
+ "logger",
35
+ "exception",
36
+ "culprit",
37
+ "title",
38
+ "message",
39
+ ];
40
+ export const RULE_MATCHES = ["is", "starts", "contains"];
41
+ export const FIELD_TYPES = ["text", "number"];
42
+ /**
43
+ * Declares an app's levels, categories, fields and rules. Returns the schema
44
+ * as given: plain data in the shape the server takes, so it can be sent as
45
+ * it is. Its names become types: pass it to init as `schema` and the capture
46
+ * functions init returns take only these names.
47
+ */
48
+ export function defineSchema(schema) {
49
+ return schema;
50
+ }
51
+ // At runtime
52
+ /** The Sentry level a level of the schema is sent as. */
53
+ function sentAs(name, value, logger) {
54
+ const settings = isObject(value) ? value : {};
55
+ const like = typeof value === "string" ? value : settings.like;
56
+ if (isSentryLevel(like))
57
+ return like;
58
+ if (like === "trace")
59
+ return "debug";
60
+ if (like !== undefined || (typeof value !== "string" && !isObject(value))) {
61
+ logger.warn(`Level "${name}" in the schema: like should be ${LIKES.join(", ")}; sent as error.`);
62
+ return "error";
63
+ }
64
+ const severity = settings.severity;
65
+ if (typeof severity !== "number" || severity < 1 || severity > 24)
66
+ return "error";
67
+ if (severity >= 21)
68
+ return "fatal";
69
+ if (severity >= 17)
70
+ return "error";
71
+ if (severity >= 13)
72
+ return "warning";
73
+ if (severity >= 9)
74
+ return "info";
75
+ return "debug";
76
+ }
77
+ /**
78
+ * The Sentry level each level of init's `schema` is sent as. Built-in
79
+ * levels are left out; names the SDK cannot send are logged in debug mode.
80
+ */
81
+ export function readSchema(schema, logger) {
82
+ const out = {};
83
+ if (schema === undefined || schema === null)
84
+ return out;
85
+ if (!isObject(schema) || Array.isArray(schema)) {
86
+ logger.warn("Ignored `schema`: pass what defineSchema returns.");
87
+ return out;
88
+ }
89
+ const { levels, categories } = schema;
90
+ if (isObject(levels) && !Array.isArray(levels)) {
91
+ for (const [name, value] of Object.entries(levels)) {
92
+ // A built-in level, given a label or a colour.
93
+ if (isSentryLevel(name))
94
+ continue;
95
+ if (isLevelName(name))
96
+ out[name] = sentAs(name, value, logger);
97
+ else
98
+ logger.warn(`Ignored level "${name}" in the schema: ${LEVEL_RULE}.`);
99
+ }
100
+ }
101
+ else if (levels !== undefined && levels !== null) {
102
+ logger.warn('Ignored the schema\'s levels: they should be an object such as { "payment-failed": "error" }.');
103
+ }
104
+ const names = Array.isArray(categories)
105
+ ? categories
106
+ : isObject(categories)
107
+ ? Object.keys(categories)
108
+ : [];
109
+ for (const name of names) {
110
+ if (!isCategoryName(name))
111
+ logger.warn(`Category "${safeString(name)}" in the schema cannot be sent: ${CATEGORY_RULE}.`);
112
+ }
113
+ return out;
114
+ }
@@ -1,3 +1,3 @@
1
1
  export declare const SDK_NAME = "statoss.javascript";
2
- export declare const SDK_VERSION = "0.2.0";
2
+ export declare const SDK_VERSION = "0.3.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.2.0";
3
+ export const SDK_VERSION = "0.3.0";
4
4
  export const SDK_PACKAGE = "npm:@statoss/sdk";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statoss/sdk",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Error tracking for StatOSS: browsers, Node, Bun, Deno, Cloudflare Workers and Vercel Edge. Speaks Sentry's protocol.",
5
5
  "license": "MIT",
6
6
  "author": "StatOSS",